refactor: flatten monorepo structure to backend/ frontend/ devops/
Rename subdirectories for a cleaner single-repo layout: - website-monitoring-backend/ → backend/ - website-monitoring-frontend/ → frontend/ - website-monitoring-devops/ → devops/ Update all references in package.json scripts, CI workflows, docker-compose, pre-commit hooks, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"task-master-ai": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"env": {
|
||||
"OLLAMA_BASE_URL": "http://localhost:11434/api",
|
||||
"MODEL": "devstral:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
|
||||
globs: .cursor/rules/*.mdc
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
- **Required Rule Structure:**
|
||||
```markdown
|
||||
---
|
||||
description: Clear, one-line description of what the rule enforces
|
||||
globs: path/to/files/*.ext, other/path/**/*
|
||||
alwaysApply: boolean
|
||||
---
|
||||
|
||||
- **Main Points in Bold**
|
||||
- Sub-points with details
|
||||
- Examples and explanations
|
||||
```
|
||||
|
||||
- **File References:**
|
||||
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
|
||||
- Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references
|
||||
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
|
||||
|
||||
- **Code Examples:**
|
||||
- Use language-specific code blocks
|
||||
```typescript
|
||||
// ✅ DO: Show good examples
|
||||
const goodExample = true;
|
||||
|
||||
// ❌ DON'T: Show anti-patterns
|
||||
const badExample = false;
|
||||
```
|
||||
|
||||
- **Rule Content Guidelines:**
|
||||
- Start with high-level overview
|
||||
- Include specific, actionable requirements
|
||||
- Show examples of correct implementation
|
||||
- Reference existing code when possible
|
||||
- Keep rules DRY by referencing other rules
|
||||
|
||||
- **Rule Maintenance:**
|
||||
- Update rules when new patterns emerge
|
||||
- Add examples from actual codebase
|
||||
- Remove outdated patterns
|
||||
- Cross-reference related rules
|
||||
|
||||
- **Best Practices:**
|
||||
- Use bullet points for clarity
|
||||
- Keep descriptions concise
|
||||
- Include both DO and DON'T examples
|
||||
- Reference actual code over theoretical examples
|
||||
- Use consistent formatting across rules
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.
|
||||
globs: **/*
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
- **Rule Improvement Triggers:**
|
||||
- New code patterns not covered by existing rules
|
||||
- Repeated similar implementations across files
|
||||
- Common error patterns that could be prevented
|
||||
- New libraries or tools being used consistently
|
||||
- Emerging best practices in the codebase
|
||||
|
||||
- **Analysis Process:**
|
||||
- Compare new code with existing rules
|
||||
- Identify patterns that should be standardized
|
||||
- Look for references to external documentation
|
||||
- Check for consistent error handling patterns
|
||||
- Monitor test patterns and coverage
|
||||
|
||||
- **Rule Updates:**
|
||||
- **Add New Rules When:**
|
||||
- A new technology/pattern is used in 3+ files
|
||||
- Common bugs could be prevented by a rule
|
||||
- Code reviews repeatedly mention the same feedback
|
||||
- New security or performance patterns emerge
|
||||
|
||||
- **Modify Existing Rules When:**
|
||||
- Better examples exist in the codebase
|
||||
- Additional edge cases are discovered
|
||||
- Related rules have been updated
|
||||
- Implementation details have changed
|
||||
|
||||
- **Example Pattern Recognition:**
|
||||
```typescript
|
||||
// If you see repeated patterns like:
|
||||
const data = await prisma.user.findMany({
|
||||
select: { id: true, email: true },
|
||||
where: { status: 'ACTIVE' }
|
||||
});
|
||||
|
||||
// Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
|
||||
// - Standard select fields
|
||||
// - Common where conditions
|
||||
// - Performance optimization patterns
|
||||
```
|
||||
|
||||
- **Rule Quality Checks:**
|
||||
- Rules should be actionable and specific
|
||||
- Examples should come from actual code
|
||||
- References should be up to date
|
||||
- Patterns should be consistently enforced
|
||||
|
||||
- **Continuous Improvement:**
|
||||
- Monitor code review comments
|
||||
- Track common development questions
|
||||
- Update rules after major refactors
|
||||
- Add links to relevant documentation
|
||||
- Cross-reference related rules
|
||||
|
||||
- **Rule Deprecation:**
|
||||
- Mark outdated patterns as deprecated
|
||||
- Remove rules that no longer apply
|
||||
- Update references to deprecated rules
|
||||
- Document migration paths for old patterns
|
||||
|
||||
- **Documentation Updates:**
|
||||
- Keep examples synchronized with code
|
||||
- Update references to external docs
|
||||
- Maintain links between related rules
|
||||
- Document breaking changes
|
||||
Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.
|
||||
@@ -0,0 +1,424 @@
|
||||
---
|
||||
description: Guide for using Taskmaster to manage task-driven development workflows
|
||||
globs: **/*
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Taskmaster Development Workflow
|
||||
|
||||
This guide outlines the standard process for using Taskmaster to manage software development projects. It is written as a set of instructions for you, the AI agent.
|
||||
|
||||
- **Your Default Stance**: For most projects, the user can work directly within the `master` task context. Your initial actions should operate on this default context unless a clear pattern for multi-context work emerges.
|
||||
- **Your Goal**: Your role is to elevate the user's workflow by intelligently introducing advanced features like **Tagged Task Lists** when you detect the appropriate context. Do not force tags on the user; suggest them as a helpful solution to a specific need.
|
||||
|
||||
## The Basic Loop
|
||||
The fundamental development cycle you will facilitate is:
|
||||
1. **`list`**: Show the user what needs to be done.
|
||||
2. **`next`**: Help the user decide what to work on.
|
||||
3. **`show <id>`**: Provide details for a specific task.
|
||||
4. **`expand <id>`**: Break down a complex task into smaller, manageable subtasks.
|
||||
5. **Implement**: The user writes the code and tests.
|
||||
6. **`update-subtask`**: Log progress and findings on behalf of the user.
|
||||
7. **`set-status`**: Mark tasks and subtasks as `done` as work is completed.
|
||||
8. **Repeat**.
|
||||
|
||||
All your standard command executions should operate on the user's current task context, which defaults to `master`.
|
||||
|
||||
---
|
||||
|
||||
## Standard Development Workflow Process
|
||||
|
||||
### Simple Workflow (Default Starting Point)
|
||||
|
||||
For new projects or when users are getting started, operate within the `master` tag context:
|
||||
|
||||
- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input='<prd-file.txt>'` (see @`taskmaster.mdc`) to generate initial tasks.json with tagged structure
|
||||
- Configure rule sets during initialization with `--rules` flag (e.g., `task-master init --rules cursor,windsurf`) or manage them later with `task-master rules add/remove` commands
|
||||
- Begin coding sessions with `get_tasks` / `task-master list` (see @`taskmaster.mdc`) to see current tasks, status, and IDs
|
||||
- Determine the next task to work on using `next_task` / `task-master next` (see @`taskmaster.mdc`)
|
||||
- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see @`taskmaster.mdc`) before breaking down tasks
|
||||
- Review complexity report using `complexity_report` / `task-master complexity-report` (see @`taskmaster.mdc`)
|
||||
- Select tasks based on dependencies (all marked 'done'), priority level, and ID order
|
||||
- View specific task details using `get_task` / `task-master show <id>` (see @`taskmaster.mdc`) to understand implementation requirements
|
||||
- Break down complex tasks using `expand_task` / `task-master expand --id=<id> --force --research` (see @`taskmaster.mdc`) with appropriate flags like `--force` (to replace existing subtasks) and `--research`
|
||||
- Implement code following task details, dependencies, and project standards
|
||||
- Mark completed tasks with `set_task_status` / `task-master set-status --id=<id> --status=done` (see @`taskmaster.mdc`)
|
||||
- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from=<id> --prompt="..."` or `update_task` / `task-master update-task --id=<id> --prompt="..."` (see @`taskmaster.mdc`)
|
||||
|
||||
---
|
||||
|
||||
## Leveling Up: Agent-Led Multi-Context Workflows
|
||||
|
||||
While the basic workflow is powerful, your primary opportunity to add value is by identifying when to introduce **Tagged Task Lists**. These patterns are your tools for creating a more organized and efficient development environment for the user, especially if you detect agentic or parallel development happening across the same session.
|
||||
|
||||
**Critical Principle**: Most users should never see a difference in their experience. Only introduce advanced workflows when you detect clear indicators that the project has evolved beyond simple task management.
|
||||
|
||||
### When to Introduce Tags: Your Decision Patterns
|
||||
|
||||
Here are the patterns to look for. When you detect one, you should propose the corresponding workflow to the user.
|
||||
|
||||
#### Pattern 1: Simple Git Feature Branching
|
||||
This is the most common and direct use case for tags.
|
||||
|
||||
- **Trigger**: The user creates a new git branch (e.g., `git checkout -b feature/user-auth`).
|
||||
- **Your Action**: Propose creating a new tag that mirrors the branch name to isolate the feature's tasks from `master`.
|
||||
- **Your Suggested Prompt**: *"I see you've created a new branch named 'feature/user-auth'. To keep all related tasks neatly organized and separate from your main list, I can create a corresponding task tag for you. This helps prevent merge conflicts in your `tasks.json` file later. Shall I create the 'feature-user-auth' tag?"*
|
||||
- **Tool to Use**: `task-master add-tag --from-branch`
|
||||
|
||||
#### Pattern 2: Team Collaboration
|
||||
- **Trigger**: The user mentions working with teammates (e.g., "My teammate Alice is handling the database schema," or "I need to review Bob's work on the API.").
|
||||
- **Your Action**: Suggest creating a separate tag for the user's work to prevent conflicts with shared master context.
|
||||
- **Your Suggested Prompt**: *"Since you're working with Alice, I can create a separate task context for your work to avoid conflicts. This way, Alice can continue working with the master list while you have your own isolated context. When you're ready to merge your work, we can coordinate the tasks back to master. Shall I create a tag for your current work?"*
|
||||
- **Tool to Use**: `task-master add-tag my-work --copy-from-current --description="My tasks while collaborating with Alice"`
|
||||
|
||||
#### Pattern 3: Experiments or Risky Refactors
|
||||
- **Trigger**: The user wants to try something that might not be kept (e.g., "I want to experiment with switching our state management library," or "Let's refactor the old API module, but I want to keep the current tasks as a reference.").
|
||||
- **Your Action**: Propose creating a sandboxed tag for the experimental work.
|
||||
- **Your Suggested Prompt**: *"This sounds like a great experiment. To keep these new tasks separate from our main plan, I can create a temporary 'experiment-zustand' tag for this work. If we decide not to proceed, we can simply delete the tag without affecting the main task list. Sound good?"*
|
||||
- **Tool to Use**: `task-master add-tag experiment-zustand --description="Exploring Zustand migration"`
|
||||
|
||||
#### Pattern 4: Large Feature Initiatives (PRD-Driven)
|
||||
This is a more structured approach for significant new features or epics.
|
||||
|
||||
- **Trigger**: The user describes a large, multi-step feature that would benefit from a formal plan.
|
||||
- **Your Action**: Propose a comprehensive, PRD-driven workflow.
|
||||
- **Your Suggested Prompt**: *"This sounds like a significant new feature. To manage this effectively, I suggest we create a dedicated task context for it. Here's the plan: I'll create a new tag called 'feature-xyz', then we can draft a Product Requirements Document (PRD) together to scope the work. Once the PRD is ready, I'll automatically generate all the necessary tasks within that new tag. How does that sound?"*
|
||||
- **Your Implementation Flow**:
|
||||
1. **Create an empty tag**: `task-master add-tag feature-xyz --description "Tasks for the new XYZ feature"`. You can also start by creating a git branch if applicable, and then create the tag from that branch.
|
||||
2. **Collaborate & Create PRD**: Work with the user to create a detailed PRD file (e.g., `.taskmaster/docs/feature-xyz-prd.txt`).
|
||||
3. **Parse PRD into the new tag**: `task-master parse-prd .taskmaster/docs/feature-xyz-prd.txt --tag feature-xyz`
|
||||
4. **Prepare the new task list**: Follow up by suggesting `analyze-complexity` and `expand-all` for the newly created tasks within the `feature-xyz` tag.
|
||||
|
||||
#### Pattern 5: Version-Based Development
|
||||
Tailor your approach based on the project maturity indicated by tag names.
|
||||
|
||||
- **Prototype/MVP Tags** (`prototype`, `mvp`, `poc`, `v0.x`):
|
||||
- **Your Approach**: Focus on speed and functionality over perfection
|
||||
- **Task Generation**: Create tasks that emphasize "get it working" over "get it perfect"
|
||||
- **Complexity Level**: Lower complexity, fewer subtasks, more direct implementation paths
|
||||
- **Research Prompts**: Include context like "This is a prototype - prioritize speed and basic functionality over optimization"
|
||||
- **Example Prompt Addition**: *"Since this is for the MVP, I'll focus on tasks that get core functionality working quickly rather than over-engineering."*
|
||||
|
||||
- **Production/Mature Tags** (`v1.0+`, `production`, `stable`):
|
||||
- **Your Approach**: Emphasize robustness, testing, and maintainability
|
||||
- **Task Generation**: Include comprehensive error handling, testing, documentation, and optimization
|
||||
- **Complexity Level**: Higher complexity, more detailed subtasks, thorough implementation paths
|
||||
- **Research Prompts**: Include context like "This is for production - prioritize reliability, performance, and maintainability"
|
||||
- **Example Prompt Addition**: *"Since this is for production, I'll ensure tasks include proper error handling, testing, and documentation."*
|
||||
|
||||
### Advanced Workflow (Tag-Based & PRD-Driven)
|
||||
|
||||
**When to Transition**: Recognize when the project has evolved (or has initiated a project which existing code) beyond simple task management. Look for these indicators:
|
||||
- User mentions teammates or collaboration needs
|
||||
- Project has grown to 15+ tasks with mixed priorities
|
||||
- User creates feature branches or mentions major initiatives
|
||||
- User initializes Taskmaster on an existing, complex codebase
|
||||
- User describes large features that would benefit from dedicated planning
|
||||
|
||||
**Your Role in Transition**: Guide the user to a more sophisticated workflow that leverages tags for organization and PRDs for comprehensive planning.
|
||||
|
||||
#### Master List Strategy (High-Value Focus)
|
||||
Once you transition to tag-based workflows, the `master` tag should ideally contain only:
|
||||
- **High-level deliverables** that provide significant business value
|
||||
- **Major milestones** and epic-level features
|
||||
- **Critical infrastructure** work that affects the entire project
|
||||
- **Release-blocking** items
|
||||
|
||||
**What NOT to put in master**:
|
||||
- Detailed implementation subtasks (these go in feature-specific tags' parent tasks)
|
||||
- Refactoring work (create dedicated tags like `refactor-auth`)
|
||||
- Experimental features (use `experiment-*` tags)
|
||||
- Team member-specific tasks (use person-specific tags)
|
||||
|
||||
#### PRD-Driven Feature Development
|
||||
|
||||
**For New Major Features**:
|
||||
1. **Identify the Initiative**: When user describes a significant feature
|
||||
2. **Create Dedicated Tag**: `add_tag feature-[name] --description="[Feature description]"`
|
||||
3. **Collaborative PRD Creation**: Work with user to create comprehensive PRD in `.taskmaster/docs/feature-[name]-prd.txt`
|
||||
4. **Parse & Prepare**:
|
||||
- `parse_prd .taskmaster/docs/feature-[name]-prd.txt --tag=feature-[name]`
|
||||
- `analyze_project_complexity --tag=feature-[name] --research`
|
||||
- `expand_all --tag=feature-[name] --research`
|
||||
5. **Add Master Reference**: Create a high-level task in `master` that references the feature tag
|
||||
|
||||
**For Existing Codebase Analysis**:
|
||||
When users initialize Taskmaster on existing projects:
|
||||
1. **Codebase Discovery**: Use your native tools for producing deep context about the code base. You may use `research` tool with `--tree` and `--files` to collect up to date information using the existing architecture as context.
|
||||
2. **Collaborative Assessment**: Work with user to identify improvement areas, technical debt, or new features
|
||||
3. **Strategic PRD Creation**: Co-author PRDs that include:
|
||||
- Current state analysis (based on your codebase research)
|
||||
- Proposed improvements or new features
|
||||
- Implementation strategy considering existing code
|
||||
4. **Tag-Based Organization**: Parse PRDs into appropriate tags (`refactor-api`, `feature-dashboard`, `tech-debt`, etc.)
|
||||
5. **Master List Curation**: Keep only the most valuable initiatives in master
|
||||
|
||||
The parse-prd's `--append` flag enables the user to parse multiple PRDs within tags or across tags. PRDs should be focused and the number of tasks they are parsed into should be strategically chosen relative to the PRD's complexity and level of detail.
|
||||
|
||||
### Workflow Transition Examples
|
||||
|
||||
**Example 1: Simple → Team-Based**
|
||||
```
|
||||
User: "Alice is going to help with the API work"
|
||||
Your Response: "Great! To avoid conflicts, I'll create a separate task context for your work. Alice can continue with the master list while you work in your own context. When you're ready to merge, we can coordinate the tasks back together."
|
||||
Action: add_tag my-api-work --copy-from-current --description="My API tasks while collaborating with Alice"
|
||||
```
|
||||
|
||||
**Example 2: Simple → PRD-Driven**
|
||||
```
|
||||
User: "I want to add a complete user dashboard with analytics, user management, and reporting"
|
||||
Your Response: "This sounds like a major feature that would benefit from detailed planning. Let me create a dedicated context for this work and we can draft a PRD together to ensure we capture all requirements."
|
||||
Actions:
|
||||
1. add_tag feature-dashboard --description="User dashboard with analytics and management"
|
||||
2. Collaborate on PRD creation
|
||||
3. parse_prd dashboard-prd.txt --tag=feature-dashboard
|
||||
4. Add high-level "User Dashboard" task to master
|
||||
```
|
||||
|
||||
**Example 3: Existing Project → Strategic Planning**
|
||||
```
|
||||
User: "I just initialized Taskmaster on my existing React app. It's getting messy and I want to improve it."
|
||||
Your Response: "Let me research your codebase to understand the current architecture, then we can create a strategic plan for improvements."
|
||||
Actions:
|
||||
1. research "Current React app architecture and improvement opportunities" --tree --files=src/
|
||||
2. Collaborate on improvement PRD based on findings
|
||||
3. Create tags for different improvement areas (refactor-components, improve-state-management, etc.)
|
||||
4. Keep only major improvement initiatives in master
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Primary Interaction: MCP Server vs. CLI
|
||||
|
||||
Taskmaster offers two primary ways to interact:
|
||||
|
||||
1. **MCP Server (Recommended for Integrated Tools)**:
|
||||
- For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**.
|
||||
- The MCP server exposes Taskmaster functionality through a set of tools (e.g., `get_tasks`, `add_subtask`).
|
||||
- This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing.
|
||||
- Refer to @`mcp.mdc` for details on the MCP architecture and available tools.
|
||||
- A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in @`taskmaster.mdc`.
|
||||
- **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change.
|
||||
- **Note**: MCP tools fully support tagged task lists with complete tag management capabilities.
|
||||
|
||||
2. **`task-master` CLI (For Users & Fallback)**:
|
||||
- The global `task-master` command provides a user-friendly interface for direct terminal interaction.
|
||||
- It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP.
|
||||
- Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`.
|
||||
- The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`).
|
||||
- Refer to @`taskmaster.mdc` for a detailed command reference.
|
||||
- **Tagged Task Lists**: CLI fully supports the new tagged system with seamless migration.
|
||||
|
||||
## How the Tag System Works (For Your Reference)
|
||||
|
||||
- **Data Structure**: Tasks are organized into separate contexts (tags) like "master", "feature-branch", or "v2.0".
|
||||
- **Silent Migration**: Existing projects automatically migrate to use a "master" tag with zero disruption.
|
||||
- **Context Isolation**: Tasks in different tags are completely separate. Changes in one tag do not affect any other tag.
|
||||
- **Manual Control**: The user is always in control. There is no automatic switching. You facilitate switching by using `use-tag <name>`.
|
||||
- **Full CLI & MCP Support**: All tag management commands are available through both the CLI and MCP tools for you to use. Refer to @`taskmaster.mdc` for a full command list.
|
||||
|
||||
---
|
||||
|
||||
## Task Complexity Analysis
|
||||
|
||||
- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see @`taskmaster.mdc`) for comprehensive analysis
|
||||
- Review complexity report via `complexity_report` / `task-master complexity-report` (see @`taskmaster.mdc`) for a formatted, readable version.
|
||||
- Focus on tasks with highest complexity scores (8-10) for detailed breakdown
|
||||
- Use analysis results to determine appropriate subtask allocation
|
||||
- Note that reports are automatically used by the `expand_task` tool/command
|
||||
|
||||
## Task Breakdown Process
|
||||
|
||||
- Use `expand_task` / `task-master expand --id=<id>`. It automatically uses the complexity report if found, otherwise generates default number of subtasks.
|
||||
- Use `--num=<number>` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations.
|
||||
- Add `--research` flag to leverage Perplexity AI for research-backed expansion.
|
||||
- Add `--force` flag to clear existing subtasks before generating new ones (default is to append).
|
||||
- Use `--prompt="<context>"` to provide additional context when needed.
|
||||
- Review and adjust generated subtasks as necessary.
|
||||
- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`.
|
||||
- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=<id>`.
|
||||
|
||||
## Implementation Drift Handling
|
||||
|
||||
- When implementation differs significantly from planned approach
|
||||
- When future tasks need modification due to current implementation choices
|
||||
- When new dependencies or requirements emerge
|
||||
- Use `update` / `task-master update --from=<futureTaskId> --prompt='<explanation>\nUpdate context...' --research` to update multiple future tasks.
|
||||
- Use `update_task` / `task-master update-task --id=<taskId> --prompt='<explanation>\nUpdate context...' --research` to update a single specific task.
|
||||
|
||||
## Task Status Management
|
||||
|
||||
- Use 'pending' for tasks ready to be worked on
|
||||
- Use 'done' for completed and verified tasks
|
||||
- Use 'deferred' for postponed tasks
|
||||
- Add custom status values as needed for project-specific workflows
|
||||
|
||||
## Task Structure Fields
|
||||
|
||||
- **id**: Unique identifier for the task (Example: `1`, `1.1`)
|
||||
- **title**: Brief, descriptive title (Example: `"Initialize Repo"`)
|
||||
- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`)
|
||||
- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`)
|
||||
- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`)
|
||||
- Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending)
|
||||
- This helps quickly identify which prerequisite tasks are blocking work
|
||||
- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`)
|
||||
- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`)
|
||||
- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`)
|
||||
- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`)
|
||||
- Refer to task structure details (previously linked to `tasks.mdc`).
|
||||
|
||||
## Configuration Management (Updated)
|
||||
|
||||
Taskmaster configuration is managed through two main mechanisms:
|
||||
|
||||
1. **`.taskmaster/config.json` File (Primary):**
|
||||
* Located in the project root directory.
|
||||
* Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc.
|
||||
* **Tagged System Settings**: Includes `global.defaultTag` (defaults to "master") and `tags` section for tag management configuration.
|
||||
* **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing.
|
||||
* **View/Set specific models via `task-master models` command or `models` MCP tool.**
|
||||
* Created automatically when you run `task-master models --setup` for the first time or during tagged system migration.
|
||||
|
||||
2. **Environment Variables (`.env` / `mcp.json`):**
|
||||
* Used **only** for sensitive API keys and specific endpoint URLs.
|
||||
* Place API keys (one per provider) in a `.env` file in the project root for CLI usage.
|
||||
* For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`.
|
||||
* Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`).
|
||||
|
||||
3. **`.taskmaster/state.json` File (Tagged System State):**
|
||||
* Tracks current tag context and migration status.
|
||||
* Automatically created during tagged system migration.
|
||||
* Contains: `currentTag`, `lastSwitched`, `migrationNoticeShown`.
|
||||
|
||||
**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool.
|
||||
**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`.
|
||||
**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project.
|
||||
|
||||
## Rules Management
|
||||
|
||||
Taskmaster supports multiple AI coding assistant rule sets that can be configured during project initialization or managed afterward:
|
||||
|
||||
- **Available Profiles**: Claude Code, Cline, Codex, Cursor, Roo Code, Trae, Windsurf (claude, cline, codex, cursor, roo, trae, windsurf)
|
||||
- **During Initialization**: Use `task-master init --rules cursor,windsurf` to specify which rule sets to include
|
||||
- **After Initialization**: Use `task-master rules add <profiles>` or `task-master rules remove <profiles>` to manage rule sets
|
||||
- **Interactive Setup**: Use `task-master rules setup` to launch an interactive prompt for selecting rule profiles
|
||||
- **Default Behavior**: If no `--rules` flag is specified during initialization, all available rule profiles are included
|
||||
- **Rule Structure**: Each profile creates its own directory (e.g., `.cursor/rules`, `.roo/rules`) with appropriate configuration files
|
||||
|
||||
## Determining the Next Task
|
||||
|
||||
- Run `next_task` / `task-master next` to show the next task to work on.
|
||||
- The command identifies tasks with all dependencies satisfied
|
||||
- Tasks are prioritized by priority level, dependency count, and ID
|
||||
- The command shows comprehensive task information including:
|
||||
- Basic task details and description
|
||||
- Implementation details
|
||||
- Subtasks (if they exist)
|
||||
- Contextual suggested actions
|
||||
- Recommended before starting any new development work
|
||||
- Respects your project's dependency structure
|
||||
- Ensures tasks are completed in the appropriate sequence
|
||||
- Provides ready-to-use commands for common task actions
|
||||
|
||||
## Viewing Specific Task Details
|
||||
|
||||
- Run `get_task` / `task-master show <id>` to view a specific task.
|
||||
- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1)
|
||||
- Displays comprehensive information similar to the next command, but for a specific task
|
||||
- For parent tasks, shows all subtasks and their current status
|
||||
- For subtasks, shows parent task information and relationship
|
||||
- Provides contextual suggested actions appropriate for the specific task
|
||||
- Useful for examining task details before implementation or checking status
|
||||
|
||||
## Managing Task Dependencies
|
||||
|
||||
- Use `add_dependency` / `task-master add-dependency --id=<id> --depends-on=<id>` to add a dependency.
|
||||
- Use `remove_dependency` / `task-master remove-dependency --id=<id> --depends-on=<id>` to remove a dependency.
|
||||
- The system prevents circular dependencies and duplicate dependency entries
|
||||
- Dependencies are checked for existence before being added or removed
|
||||
- Task files are automatically regenerated after dependency changes
|
||||
- Dependencies are visualized with status indicators in task listings and files
|
||||
|
||||
## Task Reorganization
|
||||
|
||||
- Use `move_task` / `task-master move --from=<id> --to=<id>` to move tasks or subtasks within the hierarchy
|
||||
- This command supports several use cases:
|
||||
- Moving a standalone task to become a subtask (e.g., `--from=5 --to=7`)
|
||||
- Moving a subtask to become a standalone task (e.g., `--from=5.2 --to=7`)
|
||||
- Moving a subtask to a different parent (e.g., `--from=5.2 --to=7.3`)
|
||||
- Reordering subtasks within the same parent (e.g., `--from=5.2 --to=5.4`)
|
||||
- Moving a task to a new, non-existent ID position (e.g., `--from=5 --to=25`)
|
||||
- Moving multiple tasks at once using comma-separated IDs (e.g., `--from=10,11,12 --to=16,17,18`)
|
||||
- The system includes validation to prevent data loss:
|
||||
- Allows moving to non-existent IDs by creating placeholder tasks
|
||||
- Prevents moving to existing task IDs that have content (to avoid overwriting)
|
||||
- Validates source tasks exist before attempting to move them
|
||||
- The system maintains proper parent-child relationships and dependency integrity
|
||||
- Task files are automatically regenerated after the move operation
|
||||
- This provides greater flexibility in organizing and refining your task structure as project understanding evolves
|
||||
- This is especially useful when dealing with potential merge conflicts arising from teams creating tasks on separate branches. Solve these conflicts very easily by moving your tasks and keeping theirs.
|
||||
|
||||
## Iterative Subtask Implementation
|
||||
|
||||
Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation:
|
||||
|
||||
1. **Understand the Goal (Preparation):**
|
||||
* Use `get_task` / `task-master show <subtaskId>` (see @`taskmaster.mdc`) to thoroughly understand the specific goals and requirements of the subtask.
|
||||
|
||||
2. **Initial Exploration & Planning (Iteration 1):**
|
||||
* This is the first attempt at creating a concrete implementation plan.
|
||||
* Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification.
|
||||
* Determine the intended code changes (diffs) and their locations.
|
||||
* Gather *all* relevant details from this exploration phase.
|
||||
|
||||
3. **Log the Plan:**
|
||||
* Run `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<detailed plan>'`.
|
||||
* Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`.
|
||||
|
||||
4. **Verify the Plan:**
|
||||
* Run `get_task` / `task-master show <subtaskId>` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details.
|
||||
|
||||
5. **Begin Implementation:**
|
||||
* Set the subtask status using `set_task_status` / `task-master set-status --id=<subtaskId> --status=in-progress`.
|
||||
* Start coding based on the logged plan.
|
||||
|
||||
6. **Refine and Log Progress (Iteration 2+):**
|
||||
* As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches.
|
||||
* **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy.
|
||||
* **Regularly** use `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<update details>\n- What worked...\n- What didn't work...'` to append new findings.
|
||||
* **Crucially, log:**
|
||||
* What worked ("fundamental truths" discovered).
|
||||
* What didn't work and why (to avoid repeating mistakes).
|
||||
* Specific code snippets or configurations that were successful.
|
||||
* Decisions made, especially if confirmed with user input.
|
||||
* Any deviations from the initial plan and the reasoning.
|
||||
* The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors.
|
||||
|
||||
7. **Review & Update Rules (Post-Implementation):**
|
||||
* Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history.
|
||||
* Identify any new or modified code patterns, conventions, or best practices established during the implementation.
|
||||
* Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`).
|
||||
|
||||
8. **Mark Task Complete:**
|
||||
* After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id=<subtaskId> --status=done`.
|
||||
|
||||
9. **Commit Changes (If using Git):**
|
||||
* Stage the relevant code changes and any updated/new rule files (`git add .`).
|
||||
* Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments.
|
||||
* Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask <subtaskId>\n\n- Details about changes...\n- Updated rule Y for pattern Z'`).
|
||||
* Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one.
|
||||
|
||||
10. **Proceed to Next Subtask:**
|
||||
* Identify the next subtask (e.g., using `next_task` / `task-master next`).
|
||||
|
||||
## Code Analysis & Refactoring Techniques
|
||||
|
||||
- **Top-Level Function Search**:
|
||||
- Useful for understanding module structure or planning refactors.
|
||||
- Use grep/ripgrep to find exported functions/constants:
|
||||
`rg "export (async function|function|const) \w+"` or similar patterns.
|
||||
- Can help compare functions between files during migrations or identify potential naming conflicts.
|
||||
|
||||
---
|
||||
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*
|
||||
@@ -0,0 +1,558 @@
|
||||
---
|
||||
description: Comprehensive reference for Taskmaster MCP tools and CLI commands.
|
||||
globs: **/*
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Taskmaster Tool & Command Reference
|
||||
|
||||
This document provides a detailed reference for interacting with Taskmaster, covering both the recommended MCP tools, suitable for integrations like Cursor, and the corresponding `task-master` CLI commands, designed for direct user interaction or fallback.
|
||||
|
||||
**Note:** For interacting with Taskmaster programmatically or via integrated tools, using the **MCP tools is strongly recommended** due to better performance, structured data, and error handling. The CLI commands serve as a user-friendly alternative and fallback.
|
||||
|
||||
**Important:** Several MCP tools involve AI processing... The AI-powered tools include `parse_prd`, `analyze_project_complexity`, `update_subtask`, `update_task`, `update`, `expand_all`, `expand_task`, and `add_task`.
|
||||
|
||||
**🏷️ Tagged Task Lists System:** Task Master now supports **tagged task lists** for multi-context task management. This allows you to maintain separate, isolated lists of tasks for different features, branches, or experiments. Existing projects are seamlessly migrated to use a default "master" tag. Most commands now support a `--tag <name>` flag to specify which context to operate on. If omitted, commands use the currently active tag.
|
||||
|
||||
---
|
||||
|
||||
## Initialization & Setup
|
||||
|
||||
### 1. Initialize Project (`init`)
|
||||
|
||||
* **MCP Tool:** `initialize_project`
|
||||
* **CLI Command:** `task-master init [options]`
|
||||
* **Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project.`
|
||||
* **Key CLI Options:**
|
||||
* `--name <name>`: `Set the name for your project in Taskmaster's configuration.`
|
||||
* `--description <text>`: `Provide a brief description for your project.`
|
||||
* `--version <version>`: `Set the initial version for your project, e.g., '0.1.0'.`
|
||||
* `-y, --yes`: `Initialize Taskmaster quickly using default settings without interactive prompts.`
|
||||
* **Usage:** Run this once at the beginning of a new project.
|
||||
* **MCP Variant Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project by running the 'task-master init' command.`
|
||||
* **Key MCP Parameters/Options:**
|
||||
* `projectName`: `Set the name for your project.` (CLI: `--name <name>`)
|
||||
* `projectDescription`: `Provide a brief description for your project.` (CLI: `--description <text>`)
|
||||
* `projectVersion`: `Set the initial version for your project, e.g., '0.1.0'.` (CLI: `--version <version>`)
|
||||
* `authorName`: `Author name.` (CLI: `--author <author>`)
|
||||
* `skipInstall`: `Skip installing dependencies. Default is false.` (CLI: `--skip-install`)
|
||||
* `addAliases`: `Add shell aliases tm and taskmaster. Default is false.` (CLI: `--aliases`)
|
||||
* `yes`: `Skip prompts and use defaults/provided arguments. Default is false.` (CLI: `-y, --yes`)
|
||||
* **Usage:** Run this once at the beginning of a new project, typically via an integrated tool like Cursor. Operates on the current working directory of the MCP server.
|
||||
* **Important:** Once complete, you *MUST* parse a prd in order to generate tasks. There will be no tasks files until then. The next step after initializing should be to create a PRD using the example PRD in .taskmaster/templates/example_prd.txt.
|
||||
* **Tagging:** Use the `--tag` option to parse the PRD into a specific, non-default tag context. If the tag doesn't exist, it will be created automatically. Example: `task-master parse-prd spec.txt --tag=new-feature`.
|
||||
|
||||
### 2. Parse PRD (`parse_prd`)
|
||||
|
||||
* **MCP Tool:** `parse_prd`
|
||||
* **CLI Command:** `task-master parse-prd [file] [options]`
|
||||
* **Description:** `Parse a Product Requirements Document, PRD, or text file with Taskmaster to automatically generate an initial set of tasks in tasks.json.`
|
||||
* **Key Parameters/Options:**
|
||||
* `input`: `Path to your PRD or requirements text file that Taskmaster should parse for tasks.` (CLI: `[file]` positional or `-i, --input <file>`)
|
||||
* `output`: `Specify where Taskmaster should save the generated 'tasks.json' file. Defaults to '.taskmaster/tasks/tasks.json'.` (CLI: `-o, --output <file>`)
|
||||
* `numTasks`: `Approximate number of top-level tasks Taskmaster should aim to generate from the document.` (CLI: `-n, --num-tasks <number>`)
|
||||
* `force`: `Use this to allow Taskmaster to overwrite an existing 'tasks.json' without asking for confirmation.` (CLI: `-f, --force`)
|
||||
* **Usage:** Useful for bootstrapping a project from an existing requirements document.
|
||||
* **Notes:** Task Master will strictly adhere to any specific requirements mentioned in the PRD, such as libraries, database schemas, frameworks, tech stacks, etc., while filling in any gaps where the PRD isn't fully specified. Tasks are designed to provide the most direct implementation path while avoiding over-engineering.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. If the user does not have a PRD, suggest discussing their idea and then use the example PRD in `.taskmaster/templates/example_prd.txt` as a template for creating the PRD based on their idea, for use with `parse-prd`.
|
||||
|
||||
---
|
||||
|
||||
## AI Model Configuration
|
||||
|
||||
### 2. Manage Models (`models`)
|
||||
* **MCP Tool:** `models`
|
||||
* **CLI Command:** `task-master models [options]`
|
||||
* **Description:** `View the current AI model configuration or set specific models for different roles (main, research, fallback). Allows setting custom model IDs for Ollama and OpenRouter.`
|
||||
* **Key MCP Parameters/Options:**
|
||||
* `setMain <model_id>`: `Set the primary model ID for task generation/updates.` (CLI: `--set-main <model_id>`)
|
||||
* `setResearch <model_id>`: `Set the model ID for research-backed operations.` (CLI: `--set-research <model_id>`)
|
||||
* `setFallback <model_id>`: `Set the model ID to use if the primary fails.` (CLI: `--set-fallback <model_id>`)
|
||||
* `ollama <boolean>`: `Indicates the set model ID is a custom Ollama model.` (CLI: `--ollama`)
|
||||
* `openrouter <boolean>`: `Indicates the set model ID is a custom OpenRouter model.` (CLI: `--openrouter`)
|
||||
* `listAvailableModels <boolean>`: `If true, lists available models not currently assigned to a role.` (CLI: No direct equivalent; CLI lists available automatically)
|
||||
* `projectRoot <string>`: `Optional. Absolute path to the project root directory.` (CLI: Determined automatically)
|
||||
* **Key CLI Options:**
|
||||
* `--set-main <model_id>`: `Set the primary model.`
|
||||
* `--set-research <model_id>`: `Set the research model.`
|
||||
* `--set-fallback <model_id>`: `Set the fallback model.`
|
||||
* `--ollama`: `Specify that the provided model ID is for Ollama (use with --set-*).`
|
||||
* `--openrouter`: `Specify that the provided model ID is for OpenRouter (use with --set-*). Validates against OpenRouter API.`
|
||||
* `--bedrock`: `Specify that the provided model ID is for AWS Bedrock (use with --set-*).`
|
||||
* `--setup`: `Run interactive setup to configure models, including custom Ollama/OpenRouter IDs.`
|
||||
* **Usage (MCP):** Call without set flags to get current config. Use `setMain`, `setResearch`, or `setFallback` with a valid model ID to update the configuration. Use `listAvailableModels: true` to get a list of unassigned models. To set a custom model, provide the model ID and set `ollama: true` or `openrouter: true`.
|
||||
* **Usage (CLI):** Run without flags to view current configuration and available models. Use set flags to update specific roles. Use `--setup` for guided configuration, including custom models. To set a custom model via flags, use `--set-<role>=<model_id>` along with either `--ollama` or `--openrouter`.
|
||||
* **Notes:** Configuration is stored in `.taskmaster/config.json` in the project root. This command/tool modifies that file. Use `listAvailableModels` or `task-master models` to see internally supported models. OpenRouter custom models are validated against their live API. Ollama custom models are not validated live.
|
||||
* **API note:** API keys for selected AI providers (based on their model) need to exist in the mcp.json file to be accessible in MCP context. The API keys must be present in the local .env file for the CLI to be able to read them.
|
||||
* **Model costs:** The costs in supported models are expressed in dollars. An input/output value of 3 is $3.00. A value of 0.8 is $0.80.
|
||||
* **Warning:** DO NOT MANUALLY EDIT THE .taskmaster/config.json FILE. Use the included commands either in the MCP or CLI format as needed. Always prioritize MCP tools when available and use the CLI as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Task Listing & Viewing
|
||||
|
||||
### 3. Get Tasks (`get_tasks`)
|
||||
|
||||
* **MCP Tool:** `get_tasks`
|
||||
* **CLI Command:** `task-master list [options]`
|
||||
* **Description:** `List your Taskmaster tasks, optionally filtering by status and showing subtasks.`
|
||||
* **Key Parameters/Options:**
|
||||
* `status`: `Show only Taskmaster tasks matching this status (or multiple statuses, comma-separated), e.g., 'pending' or 'done,in-progress'.` (CLI: `-s, --status <status>`)
|
||||
* `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`)
|
||||
* `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Get an overview of the project status, often used at the start of a work session.
|
||||
|
||||
### 4. Get Next Task (`next_task`)
|
||||
|
||||
* **MCP Tool:** `next_task`
|
||||
* **CLI Command:** `task-master next [options]`
|
||||
* **Description:** `Ask Taskmaster to show the next available task you can work on, based on status and completed dependencies.`
|
||||
* **Key Parameters/Options:**
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* `tag`: `Specify which tag context to use. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* **Usage:** Identify what to work on next according to the plan.
|
||||
|
||||
### 5. Get Task Details (`get_task`)
|
||||
|
||||
* **MCP Tool:** `get_task`
|
||||
* **CLI Command:** `task-master show [id] [options]`
|
||||
* **Description:** `Display detailed information for one or more specific Taskmaster tasks or subtasks by ID.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID of the Taskmaster task (e.g., '15'), subtask (e.g., '15.2'), or a comma-separated list of IDs ('1,5,10.2') you want to view.` (CLI: `[id]` positional or `-i, --id <id>`)
|
||||
* `tag`: `Specify which tag context to get the task(s) from. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Understand the full details for a specific task. When multiple IDs are provided, a summary table is shown.
|
||||
* **CRITICAL INFORMATION** If you need to collect information from multiple tasks, use comma-separated IDs (i.e. 1,2,3) to receive an array of tasks. Do not needlessly get tasks one at a time if you need to get many as that is wasteful.
|
||||
|
||||
---
|
||||
|
||||
## Task Creation & Modification
|
||||
|
||||
### 6. Add Task (`add_task`)
|
||||
|
||||
* **MCP Tool:** `add_task`
|
||||
* **CLI Command:** `task-master add-task [options]`
|
||||
* **Description:** `Add a new task to Taskmaster by describing it; AI will structure it.`
|
||||
* **Key Parameters/Options:**
|
||||
* `prompt`: `Required. Describe the new task you want Taskmaster to create, e.g., "Implement user authentication using JWT".` (CLI: `-p, --prompt <text>`)
|
||||
* `dependencies`: `Specify the IDs of any Taskmaster tasks that must be completed before this new one can start, e.g., '12,14'.` (CLI: `-d, --dependencies <ids>`)
|
||||
* `priority`: `Set the priority for the new task: 'high', 'medium', or 'low'. Default is 'medium'.` (CLI: `--priority <priority>`)
|
||||
* `research`: `Enable Taskmaster to use the research role for potentially more informed task creation.` (CLI: `-r, --research`)
|
||||
* `tag`: `Specify which tag context to add the task to. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Quickly add newly identified tasks during development.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 7. Add Subtask (`add_subtask`)
|
||||
|
||||
* **MCP Tool:** `add_subtask`
|
||||
* **CLI Command:** `task-master add-subtask [options]`
|
||||
* **Description:** `Add a new subtask to a Taskmaster parent task, or convert an existing task into a subtask.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id` / `parent`: `Required. The ID of the Taskmaster task that will be the parent.` (MCP: `id`, CLI: `-p, --parent <id>`)
|
||||
* `taskId`: `Use this if you want to convert an existing top-level Taskmaster task into a subtask of the specified parent.` (CLI: `-i, --task-id <id>`)
|
||||
* `title`: `Required if not using taskId. The title for the new subtask Taskmaster should create.` (CLI: `-t, --title <title>`)
|
||||
* `description`: `A brief description for the new subtask.` (CLI: `-d, --description <text>`)
|
||||
* `details`: `Provide implementation notes or details for the new subtask.` (CLI: `--details <text>`)
|
||||
* `dependencies`: `Specify IDs of other tasks or subtasks, e.g., '15' or '16.1', that must be done before this new subtask.` (CLI: `--dependencies <ids>`)
|
||||
* `status`: `Set the initial status for the new subtask. Default is 'pending'.` (CLI: `-s, --status <status>`)
|
||||
* `generate`: `Enable Taskmaster to regenerate markdown task files after adding the subtask.` (CLI: `--generate`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Break down tasks manually or reorganize existing tasks.
|
||||
|
||||
### 8. Update Tasks (`update`)
|
||||
|
||||
* **MCP Tool:** `update`
|
||||
* **CLI Command:** `task-master update [options]`
|
||||
* **Description:** `Update multiple upcoming tasks in Taskmaster based on new context or changes, starting from a specific task ID.`
|
||||
* **Key Parameters/Options:**
|
||||
* `from`: `Required. The ID of the first task Taskmaster should update. All tasks with this ID or higher that are not 'done' will be considered.` (CLI: `--from <id>`)
|
||||
* `prompt`: `Required. Explain the change or new context for Taskmaster to apply to the tasks, e.g., "We are now using React Query instead of Redux Toolkit for data fetching".` (CLI: `-p, --prompt <text>`)
|
||||
* `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Handle significant implementation changes or pivots that affect multiple future tasks. Example CLI: `task-master update --from='18' --prompt='Switching to React Query.\nNeed to refactor data fetching...'`
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 9. Update Task (`update_task`)
|
||||
|
||||
* **MCP Tool:** `update_task`
|
||||
* **CLI Command:** `task-master update-task [options]`
|
||||
* **Description:** `Modify a specific Taskmaster task by ID, incorporating new information or changes. By default, this replaces the existing task details.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The specific ID of the Taskmaster task, e.g., '15', you want to update.` (CLI: `-i, --id <id>`)
|
||||
* `prompt`: `Required. Explain the specific changes or provide the new information Taskmaster should incorporate into this task.` (CLI: `-p, --prompt <text>`)
|
||||
* `append`: `If true, appends the prompt content to the task's details with a timestamp, rather than replacing them. Behaves like update-subtask.` (CLI: `--append`)
|
||||
* `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`)
|
||||
* `tag`: `Specify which tag context the task belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Refine a specific task based on new understanding. Use `--append` to log progress without creating subtasks.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 10. Update Subtask (`update_subtask`)
|
||||
|
||||
* **MCP Tool:** `update_subtask`
|
||||
* **CLI Command:** `task-master update-subtask [options]`
|
||||
* **Description:** `Append timestamped notes or details to a specific Taskmaster subtask without overwriting existing content. Intended for iterative implementation logging.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID of the Taskmaster subtask, e.g., '5.2', to update with new information.` (CLI: `-i, --id <id>`)
|
||||
* `prompt`: `Required. The information, findings, or progress notes to append to the subtask's details with a timestamp.` (CLI: `-p, --prompt <text>`)
|
||||
* `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`)
|
||||
* `tag`: `Specify which tag context the subtask belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Log implementation progress, findings, and discoveries during subtask development. Each update is timestamped and appended to preserve the implementation journey.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 11. Set Task Status (`set_task_status`)
|
||||
|
||||
* **MCP Tool:** `set_task_status`
|
||||
* **CLI Command:** `task-master set-status [options]`
|
||||
* **Description:** `Update the status of one or more Taskmaster tasks or subtasks, e.g., 'pending', 'in-progress', 'done'.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID(s) of the Taskmaster task(s) or subtask(s), e.g., '15', '15.2', or '16,17.1', to update.` (CLI: `-i, --id <id>`)
|
||||
* `status`: `Required. The new status to set, e.g., 'done', 'pending', 'in-progress', 'review', 'cancelled'.` (CLI: `-s, --status <status>`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Mark progress as tasks move through the development cycle.
|
||||
|
||||
### 12. Remove Task (`remove_task`)
|
||||
|
||||
* **MCP Tool:** `remove_task`
|
||||
* **CLI Command:** `task-master remove-task [options]`
|
||||
* **Description:** `Permanently remove a task or subtask from the Taskmaster tasks list.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID of the Taskmaster task, e.g., '5', or subtask, e.g., '5.2', to permanently remove.` (CLI: `-i, --id <id>`)
|
||||
* `yes`: `Skip the confirmation prompt and immediately delete the task.` (CLI: `-y, --yes`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Permanently delete tasks or subtasks that are no longer needed in the project.
|
||||
* **Notes:** Use with caution as this operation cannot be undone. Consider using 'blocked', 'cancelled', or 'deferred' status instead if you just want to exclude a task from active planning but keep it for reference. The command automatically cleans up dependency references in other tasks.
|
||||
|
||||
---
|
||||
|
||||
## Task Structure & Breakdown
|
||||
|
||||
### 13. Expand Task (`expand_task`)
|
||||
|
||||
* **MCP Tool:** `expand_task`
|
||||
* **CLI Command:** `task-master expand [options]`
|
||||
* **Description:** `Use Taskmaster's AI to break down a complex task into smaller, manageable subtasks. Appends subtasks by default.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `The ID of the specific Taskmaster task you want to break down into subtasks.` (CLI: `-i, --id <id>`)
|
||||
* `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create. Uses complexity analysis/defaults otherwise.` (CLI: `-n, --num <number>`)
|
||||
* `research`: `Enable Taskmaster to use the research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`)
|
||||
* `prompt`: `Optional: Provide extra context or specific instructions to Taskmaster for generating the subtasks.` (CLI: `-p, --prompt <text>`)
|
||||
* `force`: `Optional: If true, clear existing subtasks before generating new ones. Default is false (append).` (CLI: `--force`)
|
||||
* `tag`: `Specify which tag context the task belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Generate a detailed implementation plan for a complex task before starting coding. Automatically uses complexity report recommendations if available and `num` is not specified.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 14. Expand All Tasks (`expand_all`)
|
||||
|
||||
* **MCP Tool:** `expand_all`
|
||||
* **CLI Command:** `task-master expand --all [options]` (Note: CLI uses the `expand` command with the `--all` flag)
|
||||
* **Description:** `Tell Taskmaster to automatically expand all eligible pending/in-progress tasks based on complexity analysis or defaults. Appends subtasks by default.`
|
||||
* **Key Parameters/Options:**
|
||||
* `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create per task.` (CLI: `-n, --num <number>`)
|
||||
* `research`: `Enable research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`)
|
||||
* `prompt`: `Optional: Provide extra context for Taskmaster to apply generally during expansion.` (CLI: `-p, --prompt <text>`)
|
||||
* `force`: `Optional: If true, clear existing subtasks before generating new ones for each eligible task. Default is false (append).` (CLI: `--force`)
|
||||
* `tag`: `Specify which tag context to expand. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Useful after initial task generation or complexity analysis to break down multiple tasks at once.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 15. Clear Subtasks (`clear_subtasks`)
|
||||
|
||||
* **MCP Tool:** `clear_subtasks`
|
||||
* **CLI Command:** `task-master clear-subtasks [options]`
|
||||
* **Description:** `Remove all subtasks from one or more specified Taskmaster parent tasks.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `The ID(s) of the Taskmaster parent task(s) whose subtasks you want to remove, e.g., '15' or '16,18'. Required unless using 'all'.` (CLI: `-i, --id <ids>`)
|
||||
* `all`: `Tell Taskmaster to remove subtasks from all parent tasks.` (CLI: `--all`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Used before regenerating subtasks with `expand_task` if the previous breakdown needs replacement.
|
||||
|
||||
### 16. Remove Subtask (`remove_subtask`)
|
||||
|
||||
* **MCP Tool:** `remove_subtask`
|
||||
* **CLI Command:** `task-master remove-subtask [options]`
|
||||
* **Description:** `Remove a subtask from its Taskmaster parent, optionally converting it into a standalone task.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID(s) of the Taskmaster subtask(s) to remove, e.g., '15.2' or '16.1,16.3'.` (CLI: `-i, --id <id>`)
|
||||
* `convert`: `If used, Taskmaster will turn the subtask into a regular top-level task instead of deleting it.` (CLI: `-c, --convert`)
|
||||
* `generate`: `Enable Taskmaster to regenerate markdown task files after removing the subtask.` (CLI: `--generate`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Delete unnecessary subtasks or promote a subtask to a top-level task.
|
||||
|
||||
### 17. Move Task (`move_task`)
|
||||
|
||||
* **MCP Tool:** `move_task`
|
||||
* **CLI Command:** `task-master move [options]`
|
||||
* **Description:** `Move a task or subtask to a new position within the task hierarchy.`
|
||||
* **Key Parameters/Options:**
|
||||
* `from`: `Required. ID of the task/subtask to move (e.g., "5" or "5.2"). Can be comma-separated for multiple tasks.` (CLI: `--from <id>`)
|
||||
* `to`: `Required. ID of the destination (e.g., "7" or "7.3"). Must match the number of source IDs if comma-separated.` (CLI: `--to <id>`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Reorganize tasks by moving them within the hierarchy. Supports various scenarios like:
|
||||
* Moving a task to become a subtask
|
||||
* Moving a subtask to become a standalone task
|
||||
* Moving a subtask to a different parent
|
||||
* Reordering subtasks within the same parent
|
||||
* Moving a task to a new, non-existent ID (automatically creates placeholders)
|
||||
* Moving multiple tasks at once with comma-separated IDs
|
||||
* **Validation Features:**
|
||||
* Allows moving tasks to non-existent destination IDs (creates placeholder tasks)
|
||||
* Prevents moving to existing task IDs that already have content (to avoid overwriting)
|
||||
* Validates that source tasks exist before attempting to move them
|
||||
* Maintains proper parent-child relationships
|
||||
* **Example CLI:** `task-master move --from=5.2 --to=7.3` to move subtask 5.2 to become subtask 7.3.
|
||||
* **Example Multi-Move:** `task-master move --from=10,11,12 --to=16,17,18` to move multiple tasks to new positions.
|
||||
* **Common Use:** Resolving merge conflicts in tasks.json when multiple team members create tasks on different branches.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Management
|
||||
|
||||
### 18. Add Dependency (`add_dependency`)
|
||||
|
||||
* **MCP Tool:** `add_dependency`
|
||||
* **CLI Command:** `task-master add-dependency [options]`
|
||||
* **Description:** `Define a dependency in Taskmaster, making one task a prerequisite for another.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID of the Taskmaster task that will depend on another.` (CLI: `-i, --id <id>`)
|
||||
* `dependsOn`: `Required. The ID of the Taskmaster task that must be completed first, the prerequisite.` (CLI: `-d, --depends-on <id>`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <path>`)
|
||||
* **Usage:** Establish the correct order of execution between tasks.
|
||||
|
||||
### 19. Remove Dependency (`remove_dependency`)
|
||||
|
||||
* **MCP Tool:** `remove_dependency`
|
||||
* **CLI Command:** `task-master remove-dependency [options]`
|
||||
* **Description:** `Remove a dependency relationship between two Taskmaster tasks.`
|
||||
* **Key Parameters/Options:**
|
||||
* `id`: `Required. The ID of the Taskmaster task you want to remove a prerequisite from.` (CLI: `-i, --id <id>`)
|
||||
* `dependsOn`: `Required. The ID of the Taskmaster task that should no longer be a prerequisite.` (CLI: `-d, --depends-on <id>`)
|
||||
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Update task relationships when the order of execution changes.
|
||||
|
||||
### 20. Validate Dependencies (`validate_dependencies`)
|
||||
|
||||
* **MCP Tool:** `validate_dependencies`
|
||||
* **CLI Command:** `task-master validate-dependencies [options]`
|
||||
* **Description:** `Check your Taskmaster tasks for dependency issues (like circular references or links to non-existent tasks) without making changes.`
|
||||
* **Key Parameters/Options:**
|
||||
* `tag`: `Specify which tag context to validate. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Audit the integrity of your task dependencies.
|
||||
|
||||
### 21. Fix Dependencies (`fix_dependencies`)
|
||||
|
||||
* **MCP Tool:** `fix_dependencies`
|
||||
* **CLI Command:** `task-master fix-dependencies [options]`
|
||||
* **Description:** `Automatically fix dependency issues (like circular references or links to non-existent tasks) in your Taskmaster tasks.`
|
||||
* **Key Parameters/Options:**
|
||||
* `tag`: `Specify which tag context to fix dependencies in. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Clean up dependency errors automatically.
|
||||
|
||||
---
|
||||
|
||||
## Analysis & Reporting
|
||||
|
||||
### 22. Analyze Project Complexity (`analyze_project_complexity`)
|
||||
|
||||
* **MCP Tool:** `analyze_project_complexity`
|
||||
* **CLI Command:** `task-master analyze-complexity [options]`
|
||||
* **Description:** `Have Taskmaster analyze your tasks to determine their complexity and suggest which ones need to be broken down further.`
|
||||
* **Key Parameters/Options:**
|
||||
* `output`: `Where to save the complexity analysis report. Default is '.taskmaster/reports/task-complexity-report.json' (or '..._tagname.json' if a tag is used).` (CLI: `-o, --output <file>`)
|
||||
* `threshold`: `The minimum complexity score (1-10) that should trigger a recommendation to expand a task.` (CLI: `-t, --threshold <number>`)
|
||||
* `research`: `Enable research role for more accurate complexity analysis. Requires appropriate API key.` (CLI: `-r, --research`)
|
||||
* `tag`: `Specify which tag context to analyze. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Used before breaking down tasks to identify which ones need the most attention.
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
|
||||
|
||||
### 23. View Complexity Report (`complexity_report`)
|
||||
|
||||
* **MCP Tool:** `complexity_report`
|
||||
* **CLI Command:** `task-master complexity-report [options]`
|
||||
* **Description:** `Display the task complexity analysis report in a readable format.`
|
||||
* **Key Parameters/Options:**
|
||||
* `tag`: `Specify which tag context to show the report for. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to the complexity report (default: '.taskmaster/reports/task-complexity-report.json').` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Review and understand the complexity analysis results after running analyze-complexity.
|
||||
|
||||
---
|
||||
|
||||
## File Management
|
||||
|
||||
### 24. Generate Task Files (`generate`)
|
||||
|
||||
* **MCP Tool:** `generate`
|
||||
* **CLI Command:** `task-master generate [options]`
|
||||
* **Description:** `Create or update individual Markdown files for each task based on your tasks.json.`
|
||||
* **Key Parameters/Options:**
|
||||
* `output`: `The directory where Taskmaster should save the task files (default: in a 'tasks' directory).` (CLI: `-o, --output <directory>`)
|
||||
* `tag`: `Specify which tag context to generate files for. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* **Usage:** Run this after making changes to tasks.json to keep individual task files up to date. This command is now manual and no longer runs automatically.
|
||||
|
||||
---
|
||||
|
||||
## AI-Powered Research
|
||||
|
||||
### 25. Research (`research`)
|
||||
|
||||
* **MCP Tool:** `research`
|
||||
* **CLI Command:** `task-master research [options]`
|
||||
* **Description:** `Perform AI-powered research queries with project context to get fresh, up-to-date information beyond the AI's knowledge cutoff.`
|
||||
* **Key Parameters/Options:**
|
||||
* `query`: `Required. Research query/prompt (e.g., "What are the latest best practices for React Query v5?").` (CLI: `[query]` positional or `-q, --query <text>`)
|
||||
* `taskIds`: `Comma-separated list of task/subtask IDs from the current tag context (e.g., "15,16.2,17").` (CLI: `-i, --id <ids>`)
|
||||
* `filePaths`: `Comma-separated list of file paths for context (e.g., "src/api.js,docs/readme.md").` (CLI: `-f, --files <paths>`)
|
||||
* `customContext`: `Additional custom context text to include in the research.` (CLI: `-c, --context <text>`)
|
||||
* `includeProjectTree`: `Include project file tree structure in context (default: false).` (CLI: `--tree`)
|
||||
* `detailLevel`: `Detail level for the research response: 'low', 'medium', 'high' (default: medium).` (CLI: `--detail <level>`)
|
||||
* `saveTo`: `Task or subtask ID (e.g., "15", "15.2") to automatically save the research conversation to.` (CLI: `--save-to <id>`)
|
||||
* `saveFile`: `If true, saves the research conversation to a markdown file in '.taskmaster/docs/research/'.` (CLI: `--save-file`)
|
||||
* `noFollowup`: `Disables the interactive follow-up question menu in the CLI.` (CLI: `--no-followup`)
|
||||
* `tag`: `Specify which tag context to use for task-based context gathering. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
* `projectRoot`: `The directory of the project. Must be an absolute path.` (CLI: Determined automatically)
|
||||
* **Usage:** **This is a POWERFUL tool that agents should use FREQUENTLY** to:
|
||||
* Get fresh information beyond knowledge cutoff dates
|
||||
* Research latest best practices, library updates, security patches
|
||||
* Find implementation examples for specific technologies
|
||||
* Validate approaches against current industry standards
|
||||
* Get contextual advice based on project files and tasks
|
||||
* **When to Consider Using Research:**
|
||||
* **Before implementing any task** - Research current best practices
|
||||
* **When encountering new technologies** - Get up-to-date implementation guidance (libraries, apis, etc)
|
||||
* **For security-related tasks** - Find latest security recommendations
|
||||
* **When updating dependencies** - Research breaking changes and migration guides
|
||||
* **For performance optimization** - Get current performance best practices
|
||||
* **When debugging complex issues** - Research known solutions and workarounds
|
||||
* **Research + Action Pattern:**
|
||||
* Use `research` to gather fresh information
|
||||
* Use `update_subtask` to commit findings with timestamps
|
||||
* Use `update_task` to incorporate research into task details
|
||||
* Use `add_task` with research flag for informed task creation
|
||||
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. The research provides FRESH data beyond the AI's training cutoff, making it invaluable for current best practices and recent developments.
|
||||
|
||||
---
|
||||
|
||||
## Tag Management
|
||||
|
||||
This new suite of commands allows you to manage different task contexts (tags).
|
||||
|
||||
### 26. List Tags (`tags`)
|
||||
|
||||
* **MCP Tool:** `list_tags`
|
||||
* **CLI Command:** `task-master tags [options]`
|
||||
* **Description:** `List all available tags with task counts, completion status, and other metadata.`
|
||||
* **Key Parameters/Options:**
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
* `--show-metadata`: `Include detailed metadata in the output (e.g., creation date, description).` (CLI: `--show-metadata`)
|
||||
|
||||
### 27. Add Tag (`add_tag`)
|
||||
|
||||
* **MCP Tool:** `add_tag`
|
||||
* **CLI Command:** `task-master add-tag <tagName> [options]`
|
||||
* **Description:** `Create a new, empty tag context, or copy tasks from another tag.`
|
||||
* **Key Parameters/Options:**
|
||||
* `tagName`: `Name of the new tag to create (alphanumeric, hyphens, underscores).` (CLI: `<tagName>` positional)
|
||||
* `--from-branch`: `Creates a tag with a name derived from the current git branch, ignoring the <tagName> argument.` (CLI: `--from-branch`)
|
||||
* `--copy-from-current`: `Copy tasks from the currently active tag to the new tag.` (CLI: `--copy-from-current`)
|
||||
* `--copy-from <tag>`: `Copy tasks from a specific source tag to the new tag.` (CLI: `--copy-from <tag>`)
|
||||
* `--description <text>`: `Provide an optional description for the new tag.` (CLI: `-d, --description <text>`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
|
||||
### 28. Delete Tag (`delete_tag`)
|
||||
|
||||
* **MCP Tool:** `delete_tag`
|
||||
* **CLI Command:** `task-master delete-tag <tagName> [options]`
|
||||
* **Description:** `Permanently delete a tag and all of its associated tasks.`
|
||||
* **Key Parameters/Options:**
|
||||
* `tagName`: `Name of the tag to delete.` (CLI: `<tagName>` positional)
|
||||
* `--yes`: `Skip the confirmation prompt.` (CLI: `-y, --yes`)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
|
||||
### 29. Use Tag (`use_tag`)
|
||||
|
||||
* **MCP Tool:** `use_tag`
|
||||
* **CLI Command:** `task-master use-tag <tagName>`
|
||||
* **Description:** `Switch your active task context to a different tag.`
|
||||
* **Key Parameters/Options:**
|
||||
* `tagName`: `Name of the tag to switch to.` (CLI: `<tagName>` positional)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
|
||||
### 30. Rename Tag (`rename_tag`)
|
||||
|
||||
* **MCP Tool:** `rename_tag`
|
||||
* **CLI Command:** `task-master rename-tag <oldName> <newName>`
|
||||
* **Description:** `Rename an existing tag.`
|
||||
* **Key Parameters/Options:**
|
||||
* `oldName`: `The current name of the tag.` (CLI: `<oldName>` positional)
|
||||
* `newName`: `The new name for the tag.` (CLI: `<newName>` positional)
|
||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||
|
||||
### 31. Copy Tag (`copy_tag`)
|
||||
|
||||
* **MCP Tool:** `copy_tag`
|
||||
* **CLI Command:** `task-master copy-tag <sourceName> <targetName> [options]`
|
||||
* **Description:** `Copy an entire tag context, including all its tasks and metadata, to a new tag.`
|
||||
* **Key Parameters/Options:**
|
||||
* `sourceName`: `Name of the tag to copy from.` (CLI: `<sourceName>` positional)
|
||||
* `targetName`: `Name of the new tag to create.` (CLI: `<targetName>` positional)
|
||||
* `--description <text>`: `Optional description for the new tag.` (CLI: `-d, --description <text>`)
|
||||
|
||||
---
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
### 32. Sync Readme (`sync-readme`) -- experimental
|
||||
|
||||
* **MCP Tool:** N/A
|
||||
* **CLI Command:** `task-master sync-readme [options]`
|
||||
* **Description:** `Exports your task list to your project's README.md file, useful for showcasing progress.`
|
||||
* **Key Parameters/Options:**
|
||||
* `status`: `Filter tasks by status (e.g., 'pending', 'done').` (CLI: `-s, --status <status>`)
|
||||
* `withSubtasks`: `Include subtasks in the export.` (CLI: `--with-subtasks`)
|
||||
* `tag`: `Specify which tag context to export from. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Configuration (Updated)
|
||||
|
||||
Taskmaster primarily uses the **`.taskmaster/config.json`** file (in project root) for configuration (models, parameters, logging level, etc.), managed via `task-master models --setup`.
|
||||
|
||||
Environment variables are used **only** for sensitive API keys related to AI providers and specific overrides like the Ollama base URL:
|
||||
|
||||
* **API Keys (Required for corresponding provider):**
|
||||
* `ANTHROPIC_API_KEY`
|
||||
* `PERPLEXITY_API_KEY`
|
||||
* `OPENAI_API_KEY`
|
||||
* `GOOGLE_API_KEY`
|
||||
* `MISTRAL_API_KEY`
|
||||
* `AZURE_OPENAI_API_KEY` (Requires `AZURE_OPENAI_ENDPOINT` too)
|
||||
* `OPENROUTER_API_KEY`
|
||||
* `XAI_API_KEY`
|
||||
* `OLLAMA_API_KEY` (Requires `OLLAMA_BASE_URL` too)
|
||||
* **Endpoints (Optional/Provider Specific inside .taskmaster/config.json):**
|
||||
* `AZURE_OPENAI_ENDPOINT`
|
||||
* `OLLAMA_BASE_URL` (Default: `http://localhost:11434/api`)
|
||||
|
||||
**Set API keys** in your **`.env`** file in the project root (for CLI use) or within the `env` section of your **`.cursor/mcp.json`** file (for MCP/Cursor integration). All other settings (model choice, max tokens, temperature, log level, custom endpoints) are managed in `.taskmaster/config.json` via `task-master models` command or `models` MCP tool.
|
||||
|
||||
---
|
||||
|
||||
For details on how these commands fit into the development process, see the [dev_workflow.mdc](mdc:.cursor/rules/taskmaster/dev_workflow.mdc).
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn"
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
name: Lighthouse Scan & Uptime Cron
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Lighthouse scans every 6 hours
|
||||
- cron: '*/5 * * * *' # Uptime checks every 5 minutes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'Job to run'
|
||||
required: true
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- scan
|
||||
- uptime
|
||||
|
||||
jobs:
|
||||
uptime:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.schedule == '*/5 * * * *' || github.event.inputs.mode == 'uptime' || github.event.inputs.mode == 'all'
|
||||
steps:
|
||||
- name: Run Uptime Checks
|
||||
run: |
|
||||
DEPLOYMENT_URL="${DEPLOYMENT_URL:-https://your-domain.com}"
|
||||
echo "Running uptime checks at: $DEPLOYMENT_URL/api/cron/uptime"
|
||||
|
||||
response=$(curl -s -w "\n%{http_code}" -H "Authorization: Bearer $CRON_SECRET" "$DEPLOYMENT_URL/api/cron/uptime")
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
echo "Status: $http_code"
|
||||
echo "Body: $response_body"
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
echo "✅ Uptime checks completed"
|
||||
else
|
||||
echo "❌ Uptime checks failed: $http_code"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ secrets.DEPLOYMENT_URL }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }} CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.schedule == '0 */6 * * *' || github.event.inputs.mode == 'scan' || github.event.inputs.mode == 'all'
|
||||
steps:
|
||||
- name: Trigger Lighthouse Scan
|
||||
run: |
|
||||
DEPLOYMENT_URL="${DEPLOYMENT_URL:-https://your-domain.com}"
|
||||
echo "Triggering scan at: $DEPLOYMENT_URL/api/cron/scan?mode=all"
|
||||
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $CRON_SECRET" "$DEPLOYMENT_URL/api/cron/scan?mode=all")
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
echo "Status: $http_code"
|
||||
echo "Body: $response_body"
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
echo "✅ Scan triggered successfully"
|
||||
else
|
||||
echo "❌ Failed to trigger scan: $http_code"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ secrets.DEPLOYMENT_URL }}
|
||||
@@ -0,0 +1,72 @@
|
||||
# dependencies
|
||||
*/node_modules
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
dev-debug.log
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
# Environment variables
|
||||
.env
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# OS specific
|
||||
|
||||
# Task files
|
||||
# tasks.json
|
||||
# tasks/
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"models": {
|
||||
"main": {
|
||||
"provider": "ollama",
|
||||
"modelId": "devstral:latest",
|
||||
"maxTokens": 128000,
|
||||
"temperature": 0.2
|
||||
},
|
||||
"research": {
|
||||
"provider": "ollama",
|
||||
"modelId": "devstral:latest",
|
||||
"maxTokens": 128000,
|
||||
"temperature": 0.1
|
||||
},
|
||||
"fallback": {
|
||||
"provider": "ollama",
|
||||
"modelId": "devstral:latest",
|
||||
"maxTokens": 128000,
|
||||
"temperature": 0.2
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"logLevel": "info",
|
||||
"debug": false,
|
||||
"defaultNumTasks": 10,
|
||||
"defaultSubtasks": 5,
|
||||
"defaultPriority": "medium",
|
||||
"projectName": "Taskmaster",
|
||||
"ollamaBaseURL": "http://localhost:11434/api",
|
||||
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
|
||||
"responseLanguage": "English",
|
||||
"defaultTag": "master",
|
||||
"azureOpenaiBaseURL": "https://your-endpoint.openai.azure.com/",
|
||||
"userId": "1234567890"
|
||||
},
|
||||
"claudeCode": {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Website Monitoring Frontend - Error Handling & Debugging PRD
|
||||
|
||||
## Project Overview
|
||||
Fix console errors in dashboard pages and improve error handling throughout the website monitoring frontend application.
|
||||
|
||||
## Current Issues
|
||||
- Console errors showing empty objects `{}` in dashboard pages
|
||||
- Poor error handling in data loading functions
|
||||
- Missing environment variables for Supabase configuration
|
||||
- Database connection issues causing data loading failures
|
||||
|
||||
## Objectives
|
||||
1. Fix console error logging to show meaningful error information
|
||||
2. Implement proper error handling and user feedback
|
||||
3. Set up proper environment variable configuration
|
||||
4. Add error boundaries and fallback UI components
|
||||
5. Improve debugging capabilities
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Error Handling Improvements
|
||||
- Replace `console.error("Error loading data:", error)` with proper error serialization
|
||||
- Add error boundaries around dashboard components
|
||||
- Implement user-friendly error messages
|
||||
- Add retry mechanisms for failed data loads
|
||||
- Create error logging utility functions
|
||||
|
||||
### Environment Configuration
|
||||
- Set up proper Supabase environment variables
|
||||
- Add environment variable validation
|
||||
- Create development and production configurations
|
||||
- Add environment variable documentation
|
||||
|
||||
### Database Connection
|
||||
- Verify Supabase connection configuration
|
||||
- Add connection health checks
|
||||
- Implement graceful degradation when database is unavailable
|
||||
- Add database schema validation
|
||||
|
||||
### UI/UX Improvements
|
||||
- Add loading states for all data operations
|
||||
- Implement error states with actionable messages
|
||||
- Add retry buttons for failed operations
|
||||
- Create fallback UI for when data is unavailable
|
||||
|
||||
## Success Criteria
|
||||
- No more empty object console errors
|
||||
- All dashboard pages show meaningful error messages
|
||||
- Users can retry failed operations
|
||||
- Application gracefully handles database connection issues
|
||||
- Environment variables are properly configured
|
||||
- Error boundaries catch and display errors appropriately
|
||||
|
||||
## Implementation Priority
|
||||
1. Fix console error logging (High)
|
||||
2. Set up environment variables (High)
|
||||
3. Add error boundaries (Medium)
|
||||
4. Implement retry mechanisms (Medium)
|
||||
5. Add comprehensive error handling (Low)
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"meta": {
|
||||
"generatedAt": "2025-07-24T15:07:47.889Z",
|
||||
"tasksAnalyzed": 9,
|
||||
"totalTasks": 10,
|
||||
"analysisCount": 9,
|
||||
"thresholdScore": 5,
|
||||
"projectName": "Taskmaster",
|
||||
"usedResearch": true
|
||||
},
|
||||
"complexityAnalysis": [
|
||||
{
|
||||
"taskId": 2,
|
||||
"taskTitle": "Implement User Authentication",
|
||||
"complexityScore": 8,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "What are the key steps to implement user authentication using NextAuth.js?",
|
||||
"reasoning": "User authentication is a critical feature that involves multiple flows and integrations, hence the high complexity score."
|
||||
},
|
||||
{
|
||||
"taskId": 3,
|
||||
"taskTitle": "Create Organization & Team Management",
|
||||
"complexityScore": 7,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "How can we implement CRUD operations for organizations and team members?",
|
||||
"reasoning": "This task involves managing user roles and permissions, which adds to its complexity."
|
||||
},
|
||||
{
|
||||
"taskId": 4,
|
||||
"taskTitle": "Add Website Management Features",
|
||||
"complexityScore": 6,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "What are the essential features for managing monitored websites?",
|
||||
"reasoning": "Website management involves multiple configurations and settings, making it moderately complex."
|
||||
},
|
||||
{
|
||||
"taskId": 5,
|
||||
"taskTitle": "Implement Real-time and Scheduled Uptime Checks",
|
||||
"complexityScore": 7,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "How can we set up real-time and scheduled uptime checks using a background job processor?",
|
||||
"reasoning": "This task requires scheduling and monitoring, which adds to its complexity."
|
||||
},
|
||||
{
|
||||
"taskId": 6,
|
||||
"taskTitle": "Develop Performance Metrics Visualization",
|
||||
"complexityScore": 7,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "What are the best practices for creating performance metrics visualizations?",
|
||||
"reasoning": "Visualizing data accurately and effectively is a complex task that requires careful planning."
|
||||
},
|
||||
{
|
||||
"taskId": 7,
|
||||
"taskTitle": "Implement Alerting & Notifications System",
|
||||
"complexityScore": 8,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "How can we set up configurable alerts and integrate an email service like SendGrid?",
|
||||
"reasoning": "This task involves multiple integrations and configurations, making it highly complex."
|
||||
},
|
||||
{
|
||||
"taskId": 8,
|
||||
"taskTitle": "Add Competitor Analysis Features",
|
||||
"complexityScore": 7,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "What are the key features for competitor analysis and benchmarking?",
|
||||
"reasoning": "Competitor analysis involves data comparison and visualization, adding to its complexity."
|
||||
},
|
||||
{
|
||||
"taskId": 9,
|
||||
"taskTitle": "Create Main Dashboard with Key Metrics",
|
||||
"complexityScore": 8,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "How can we aggregate data and create a comprehensive dashboard overview?",
|
||||
"reasoning": "Creating a main dashboard involves integrating multiple data sources and visual elements."
|
||||
},
|
||||
{
|
||||
"taskId": 10,
|
||||
"taskTitle": "Implement Responsive Design and Accessibility",
|
||||
"complexityScore": 7,
|
||||
"recommendedSubtasks": 5,
|
||||
"expansionPrompt": "What are the best practices for implementing responsive design and accessibility?",
|
||||
"reasoning": "Ensuring responsiveness and accessibility requires thorough testing and adherence to guidelines."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"currentTag": "master",
|
||||
"lastSwitched": "2025-07-24T12:45:40.600Z",
|
||||
"branchTagMapping": {},
|
||||
"migrationNoticeShown": true
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
{
|
||||
"master": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Setup Project Repository",
|
||||
"description": "Initialize the project repository with Next.js, React, TypeScript, and Tailwind CSS.",
|
||||
"details": "Create a new Next.js project using `create-next-app` with TypeScript template. Install Tailwind CSS following official documentation.",
|
||||
"testStrategy": "Verify that the project structure is correct and Tailwind CSS styles are applied.",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Implement User Authentication",
|
||||
"description": "Setup user authentication with sign up, login, password reset, and OAuth/social login support.",
|
||||
"details": "Use NextAuth.js for authentication. Implement sign-up, login, and password reset flows. Integrate OAuth providers like Google, Facebook.",
|
||||
"testStrategy": "Test all authentication flows to ensure they work correctly.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
1
|
||||
],
|
||||
"status": "in-progress",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Setup NextAuth.js Configuration",
|
||||
"description": "Configure NextAuth.js for the project.",
|
||||
"dependencies": [],
|
||||
"details": "Install NextAuth.js and configure it in your Next.js application. Set up basic authentication providers like credentials, Google, and Facebook.",
|
||||
"status": "done",
|
||||
"testStrategy": "Verify that NextAuth.js is correctly configured by attempting to log in with different providers."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Implement Sign-Up Flow",
|
||||
"description": "Create a sign-up page and handle user registration.",
|
||||
"dependencies": [
|
||||
"2.1"
|
||||
],
|
||||
"details": "Develop a sign-up form that collects user information and uses NextAuth.js to create a new user account.",
|
||||
"status": "done",
|
||||
"testStrategy": "Test the sign-up flow by creating new accounts and verifying they are stored correctly."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Implement Login Flow",
|
||||
"description": "Create a login page and handle user authentication.",
|
||||
"dependencies": [
|
||||
"2.1"
|
||||
],
|
||||
"details": "Develop a login form that uses NextAuth.js to authenticate users with their credentials or OAuth providers.",
|
||||
"status": "in-progress",
|
||||
"testStrategy": "Test the login flow by logging in with different accounts and ensuring proper redirection."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Implement Password Reset Flow",
|
||||
"description": "Create a password reset mechanism for users.",
|
||||
"dependencies": [
|
||||
"2.1"
|
||||
],
|
||||
"details": "Develop a password reset form that allows users to request a password reset and update their password using NextAuth.js.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the password reset flow by resetting passwords and verifying that users can log in with the new credentials."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Integrate OAuth Providers",
|
||||
"description": "Configure and test OAuth providers like Google and Facebook.",
|
||||
"dependencies": [
|
||||
"2.1"
|
||||
],
|
||||
"details": "Set up OAuth provider configurations in NextAuth.js for Google and Facebook. Ensure that users can log in using these providers.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the OAuth login flow by logging in with Google and Facebook accounts and verifying proper authentication."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Create Organization & Team Management",
|
||||
"description": "Allow users to create and join organizations, manage team members, and set organization-level dashboards.",
|
||||
"details": "Implement CRUD operations for organizations and team members. Create organization-specific settings and dashboards.",
|
||||
"testStrategy": "Verify that users can create organizations, invite team members, and access organization-specific dashboards.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
2
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Implement Organization CRUD Operations",
|
||||
"description": "Create endpoints and UI components to allow users to create, read, update, and delete organizations.",
|
||||
"dependencies": [],
|
||||
"details": "Use RESTful API principles for backend implementation. Create React components for frontend forms and tables.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify each CRUD operation works correctly through the UI and API."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Implement Team Member Management",
|
||||
"description": "Create functionality to add, remove, and manage team members within organizations.",
|
||||
"dependencies": [
|
||||
"3.1"
|
||||
],
|
||||
"details": "Extend organization model to include team members. Create UI components for managing team members.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test adding, removing, and updating team members through the UI."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Create Organization Settings",
|
||||
"description": "Develop settings specific to organizations that can be configured by administrators.",
|
||||
"dependencies": [
|
||||
"3.1"
|
||||
],
|
||||
"details": "Add settings model to organization schema. Create admin-only UI for configuring these settings.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify settings are saved correctly and reflected in the application."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Implement Organization Dashboards",
|
||||
"description": "Create dashboards that display organization-specific data and metrics.",
|
||||
"dependencies": [
|
||||
"3.1",
|
||||
"3.2",
|
||||
"3.3"
|
||||
],
|
||||
"details": "Design dashboard UI components. Fetch and display relevant data from the backend API.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Ensure dashboards show accurate, up-to-date information specific to each organization."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Integrate Organization Management with User Authentication",
|
||||
"description": "Ensure that only authenticated users can create and manage organizations and team members.",
|
||||
"dependencies": [],
|
||||
"details": "Use authentication middleware to protect organization management routes. Update UI components to handle authentication states.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test that unauthorized access attempts are properly blocked."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Add Website Management Features",
|
||||
"description": "Enable users to add, edit, and remove monitored websites with configurable settings.",
|
||||
"details": "Create forms for adding/editing websites. Implement configuration options like scan frequency and alert thresholds.",
|
||||
"testStrategy": "Test the website management interface to ensure all CRUD operations work correctly.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
3
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Create Website Form Component",
|
||||
"description": "Develop the form component for adding new websites.",
|
||||
"dependencies": [],
|
||||
"details": "Use React and TypeScript to create a form with fields for website URL, name, and other basic information. Implement validation for required fields.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the form rendering and validation rules."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Implement Website Addition API",
|
||||
"description": "Create an API endpoint to handle adding new websites.",
|
||||
"dependencies": [
|
||||
"4.1"
|
||||
],
|
||||
"details": "Use Next.js API routes to create an endpoint that accepts website data and stores it in the database. Ensure proper error handling and validation.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the API endpoint with various inputs, including edge cases."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Create Configuration Form Component",
|
||||
"description": "Develop the form component for configuring website settings like scan frequency and alert thresholds.",
|
||||
"dependencies": [],
|
||||
"details": "Use React and TypeScript to create a form with fields for scan frequency, alert thresholds, and other configurable settings. Implement validation for required fields.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the form rendering and validation rules."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Implement Configuration Update API",
|
||||
"description": "Create an API endpoint to handle updating website configurations.",
|
||||
"dependencies": [
|
||||
"4.3"
|
||||
],
|
||||
"details": "Use Next.js API routes to create an endpoint that accepts configuration data and updates it in the database. Ensure proper error handling and validation.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the API endpoint with various inputs, including edge cases."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Implement Website Removal Functionality",
|
||||
"description": "Create an API endpoint to handle removing websites.",
|
||||
"dependencies": [
|
||||
"4.1",
|
||||
"4.3"
|
||||
],
|
||||
"details": "Use Next.js API routes to create an endpoint that accepts a website ID and removes it from the database. Ensure proper error handling and validation.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the API endpoint with various inputs, including edge cases."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Implement Real-time and Scheduled Uptime Checks",
|
||||
"description": "Setup real-time and scheduled uptime checks for monitored websites.",
|
||||
"details": "Use a background job processor like Agenda or Bull to schedule uptime checks. Implement health check endpoints.",
|
||||
"testStrategy": "Verify that uptime checks are performed as scheduled and results are recorded.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
4
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Setup Background Job Processor",
|
||||
"description": "Choose and configure a background job processor for scheduling uptime checks.",
|
||||
"dependencies": [],
|
||||
"details": "Select either Agenda or Bull as the background job processor. Install the package using npm/yarn and set up basic configuration in your project.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the job processor is correctly installed and configured by running a simple test job."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Create Health Check Endpoint",
|
||||
"description": "Implement an endpoint to perform health checks on monitored websites.",
|
||||
"dependencies": [],
|
||||
"details": "Create an API endpoint that accepts a URL parameter and returns the status of the website (up/down). Use HTTP requests to check the website's availability.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the endpoint with various URLs to ensure it correctly reports the status of each website."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Schedule Regular Uptime Checks",
|
||||
"description": "Configure regular uptime checks using the background job processor.",
|
||||
"dependencies": [
|
||||
"5.1",
|
||||
"5.2"
|
||||
],
|
||||
"details": "Set up recurring jobs in the background job processor to call the health check endpoint at specified intervals (e.g., every 5 minutes). Store the results of each check.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that uptime checks are performed as scheduled and results are recorded correctly."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Implement Real-time Uptime Checks",
|
||||
"description": "Add functionality for real-time uptime checks on demand.",
|
||||
"dependencies": [
|
||||
"5.2"
|
||||
],
|
||||
"details": "Create an API endpoint that triggers an immediate health check and returns the result. This should bypass the scheduled job processor.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the real-time endpoint with various URLs to ensure it correctly reports the status of each website immediately."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Store and Display Uptime Check Results",
|
||||
"description": "Implement storage and display for uptime check results.",
|
||||
"dependencies": [
|
||||
"5.3"
|
||||
],
|
||||
"details": "Create a database schema to store the results of each uptime check (timestamp, URL, status). Implement a UI component to display historical uptime data.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that uptime check results are stored correctly in the database and displayed accurately in the UI."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Develop Performance Metrics Visualization",
|
||||
"description": "Create visualizations for real-time and historical performance metrics.",
|
||||
"details": "Use libraries like Recharts or Chart.js to create charts and graphs. Fetch data from the backend API.",
|
||||
"testStrategy": "Ensure that performance metrics are displayed correctly in the dashboard.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
5
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Setup Data Fetching from Backend API",
|
||||
"description": "Create functions to fetch real-time and historical performance metrics data from the backend API.",
|
||||
"dependencies": [],
|
||||
"details": "Use Axios or Fetch API to create functions that will retrieve data from predefined endpoints in the backend. Handle authentication if required.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that data is fetched correctly by checking response structure and content."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Select Charting Library",
|
||||
"description": "Choose between Recharts or Chart.js for creating visualizations based on project requirements.",
|
||||
"dependencies": [],
|
||||
"details": "Evaluate both libraries in terms of features, ease of use, and integration with the existing tech stack. Make a decision and document it.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Create simple test charts to ensure the chosen library works well within the project setup."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Design Chart Components",
|
||||
"description": "Create reusable chart components for different types of performance metrics visualizations.",
|
||||
"dependencies": [
|
||||
"6.2"
|
||||
],
|
||||
"details": "Develop React components using the selected charting library that can be reused across different parts of the application. Include props for customization.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test each component with sample data to ensure they render correctly and are responsive."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Integrate Data Fetching with Chart Components",
|
||||
"description": "Connect the chart components with the data fetching functions to display real-time and historical metrics.",
|
||||
"dependencies": [
|
||||
"6.1",
|
||||
"6.3"
|
||||
],
|
||||
"details": "Use React hooks like useEffect and useState to fetch data when components mount and update the charts accordingly.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that charts update correctly when new data is fetched and handle loading states appropriately."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Implement Real-time Data Updates",
|
||||
"description": "Setup WebSocket or long-polling to receive real-time updates for performance metrics.",
|
||||
"dependencies": [
|
||||
"6.1",
|
||||
"6.4"
|
||||
],
|
||||
"details": "Integrate a real-time data connection using WebSockets or server-sent events (SSE) to keep charts updated with the latest data.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Simulate real-time data changes and verify that charts update instantly without requiring manual refresh."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Implement Alerting & Notifications System",
|
||||
"description": "Setup configurable alerts for downtime, slow performance, or errors with email notifications.",
|
||||
"details": "Create alert configurations and integrate an email service like SendGrid. Implement in-app alert indicators.",
|
||||
"testStrategy": "Test alert triggers and ensure emails are sent as expected.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
6
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Design Alert Configuration Schema",
|
||||
"description": "Create a schema for alert configurations including conditions and thresholds.",
|
||||
"dependencies": [],
|
||||
"details": "Define JSON schema for alert configurations that includes fields like condition type (downtime, slow performance, errors), threshold values, and notification settings. Store these configurations in the database.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Validate schema against sample configurations"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Integrate Email Service",
|
||||
"description": "Set up SendGrid or similar email service for sending notifications.",
|
||||
"dependencies": [],
|
||||
"details": "Create an account on SendGrid, configure API keys, and set up a service in the application to send emails using these credentials. Implement functions to send test emails.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Send test emails through the integrated service"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Implement Alert Trigger Logic",
|
||||
"description": "Create logic to trigger alerts based on configured conditions.",
|
||||
"dependencies": [
|
||||
"7.1"
|
||||
],
|
||||
"details": "Write functions that check system metrics against alert configurations and trigger notifications when conditions are met. This can be done using background jobs or scheduled tasks.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Simulate conditions to verify alert triggers"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Create In-App Alert Indicators",
|
||||
"description": "Implement visual indicators in the application for active alerts.",
|
||||
"dependencies": [
|
||||
"7.3"
|
||||
],
|
||||
"details": "Design and implement UI components that display alert statuses (e.g., icons, badges) on relevant dashboards or pages within the application.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that alert indicators appear correctly when conditions are met"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Test End-to-End Alerting System",
|
||||
"description": "Perform end-to-end testing of the entire alerting and notification system.",
|
||||
"dependencies": [
|
||||
"7.2",
|
||||
"7.4"
|
||||
],
|
||||
"details": "Create test cases that simulate various alert conditions, verify that alerts are triggered correctly, and ensure that notifications are sent via email and displayed in-app.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Run automated tests to validate the entire workflow"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Add Competitor Analysis Features",
|
||||
"description": "Enable users to add competitor websites for benchmarking and compare performance metrics.",
|
||||
"details": "Create forms for adding competitors. Implement comparison visualizations in the dashboard.",
|
||||
"testStrategy": "Verify that competitor analysis features work correctly and comparisons are accurate.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
7
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Create Competitor Addition Form",
|
||||
"description": "Develop a form interface for users to add competitor websites.",
|
||||
"dependencies": [],
|
||||
"details": "Use React and Tailwind CSS to create the form. Include fields for website URL, name, and notes. Implement validation rules for required fields.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the form renders correctly and validates input properly."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Implement Competitor Data Storage",
|
||||
"description": "Set up backend storage for competitor website data.",
|
||||
"dependencies": [
|
||||
"8.1"
|
||||
],
|
||||
"details": "Create a database schema to store competitor information. Implement API endpoints to save and retrieve competitor data.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the API endpoints to ensure they correctly handle CRUD operations for competitor data."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Fetch Competitor Performance Metrics",
|
||||
"description": "Retrieve performance metrics for added competitors from backend services.",
|
||||
"dependencies": [
|
||||
"8.2"
|
||||
],
|
||||
"details": "Implement API calls to fetch performance metrics for each competitor website. Store the retrieved data in a structured format.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the correct performance metrics are fetched and stored for each competitor."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Develop Comparison Visualization",
|
||||
"description": "Create visualizations to compare performance metrics between user's website and competitors.",
|
||||
"dependencies": [
|
||||
"8.3"
|
||||
],
|
||||
"details": "Use a charting library like Recharts or Chart.js to create comparison charts. Display key metrics such as uptime, response time, etc.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Ensure that the visualizations accurately reflect the performance comparisons between websites."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Integrate Visualization into Dashboard",
|
||||
"description": "Add the comparison visualization to the main dashboard interface.",
|
||||
"dependencies": [
|
||||
"8.4"
|
||||
],
|
||||
"details": "Embed the created visualizations within the existing dashboard layout. Ensure they are responsive and fit well with other UI elements.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the visualizations display correctly in the dashboard and provide meaningful insights."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Create Main Dashboard with Key Metrics",
|
||||
"description": "Develop the main dashboard to display key metrics and status for all monitored websites.",
|
||||
"details": "Aggregate data from various sources and create a comprehensive overview. Use modern UI components.",
|
||||
"testStrategy": "Ensure that the main dashboard displays accurate and up-to-date information.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
8
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Design Dashboard Layout",
|
||||
"description": "Create the layout structure for the main dashboard using modern UI components.",
|
||||
"dependencies": [],
|
||||
"details": "Use a responsive grid system to create sections for different metrics. Ensure the layout is flexible and can accommodate various widgets.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the layout adapts correctly to different screen sizes."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Integrate Data Aggregation API",
|
||||
"description": "Connect the dashboard to the data aggregation service to fetch key metrics.",
|
||||
"dependencies": [
|
||||
"9.1"
|
||||
],
|
||||
"details": "Implement API calls to retrieve data from various sources and structure it for display on the dashboard.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test that data is fetched correctly and displayed accurately."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Create Metric Widgets",
|
||||
"description": "Develop reusable UI components to display individual metrics.",
|
||||
"dependencies": [
|
||||
"9.1"
|
||||
],
|
||||
"details": "Design widgets for different types of data (e.g., charts, gauges, tables) and ensure they are configurable.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that each widget displays data correctly and can be configured as needed."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Implement Status Indicators",
|
||||
"description": "Add visual indicators to show the status of monitored websites (e.g., online/offline, error alerts).",
|
||||
"dependencies": [
|
||||
"9.2",
|
||||
"9.3"
|
||||
],
|
||||
"details": "Use color coding and icons to represent different statuses clearly.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test that status indicators update correctly based on website conditions."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Add Real-time Updates",
|
||||
"description": "Enable real-time data updates for the dashboard using WebSockets or similar technology.",
|
||||
"dependencies": [
|
||||
"9.2",
|
||||
"9.3",
|
||||
"9.4"
|
||||
],
|
||||
"details": "Implement a mechanism to push updates from the server to the client and refresh the dashboard accordingly.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that changes in data are reflected on the dashboard in real-time."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Implement Responsive Design and Accessibility",
|
||||
"description": "Ensure the application is responsive and accessible on various devices.",
|
||||
"details": "Use Tailwind CSS to create a responsive layout. Follow accessibility best practices and test with tools like Lighthouse.",
|
||||
"testStrategy": "Verify that the application works well on different screen sizes and passes accessibility checks.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
9
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Integrate Tailwind CSS for Responsive Layout",
|
||||
"description": "Set up Tailwind CSS in the project to create responsive layouts.",
|
||||
"dependencies": [],
|
||||
"details": "Install Tailwind CSS via npm and configure it in your project's CSS setup. Create base styles using Tailwind's utility classes.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that basic Tailwind CSS classes are working and responsive on different screen sizes."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Create Responsive Layout Components",
|
||||
"description": "Develop key layout components using Tailwind CSS's responsive utilities.",
|
||||
"dependencies": [
|
||||
"10.1"
|
||||
],
|
||||
"details": "Implement header, footer, and main content sections with responsive behavior. Use Tailwind's grid system and breakpoints.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Test the layout on various screen sizes to ensure it adapts correctly."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Implement Accessibility Best Practices",
|
||||
"description": "Apply accessibility best practices throughout the application.",
|
||||
"dependencies": [
|
||||
"10.2"
|
||||
],
|
||||
"details": "Ensure proper use of semantic HTML, ARIA roles, and keyboard navigation support. Implement color contrast checks.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Use tools like Lighthouse to audit accessibility and fix any issues identified."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Test Accessibility with Lighthouse",
|
||||
"description": "Run accessibility tests using Lighthouse to identify and fix issues.",
|
||||
"dependencies": [
|
||||
"10.3"
|
||||
],
|
||||
"details": "Configure Lighthouse in your development environment and run regular audits. Address any critical accessibility issues found.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the application passes Lighthouse accessibility checks with a high score."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Final Responsive Design Review",
|
||||
"description": "Conduct a comprehensive review of the responsive design and accessibility implementation.",
|
||||
"dependencies": [
|
||||
"10.4"
|
||||
],
|
||||
"details": "Perform manual testing on various devices and screen sizes. Ensure all accessibility best practices are followed consistently.",
|
||||
"status": "pending",
|
||||
"testStrategy": "Verify that the application is fully responsive and accessible across different devices and browsers."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Fix Console Error Logging",
|
||||
"description": "Replace console.error with proper error serialization to show meaningful information.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"details": "Use utility functions from `src/utils/errorUtils.ts` for enhanced error logging:\n- `serializeError()`: Safely serializes error objects\n- `logError()`: Enhanced console.error with context\n- `getUserFriendlyErrorMessage()`: Converts technical errors to user-friendly messages\n- Error type detection functions: `isDatabaseError()`, `isNetworkError()`, `isAuthError()`\n\nUpdated all dashboard pages (Performance, SEO, Monitoring, Alerts) to use the new error logging with enhanced context including organization ID, function name, time range, timestamp, and user agent information.",
|
||||
"testStrategy": "Verify that console logs contain detailed error information instead of empty objects. Check that errors are logged consistently across all dashboard pages with proper context.",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Create comprehensive error utility functions in `src/utils/errorUtils.ts`",
|
||||
"description": "Implement the following utility functions:\n- `serializeError()`\n- `logError()`\n- `getUserFriendlyErrorMessage()`\n- Error type detection functions: `isDatabaseError()`, `isNetworkError()`, `isAuthError()`",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Update Performance page error logging",
|
||||
"description": "Replace `console.error('Error loading performance data:', error)` with `logError()` including context.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Update SEO page error handling",
|
||||
"description": "Update error handling to use proper serialization and enhanced context.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Enhance Monitoring page error logging",
|
||||
"description": "Improve error logging with organization context.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Improve Alerts page error handling",
|
||||
"description": "Update error handling to include detailed context.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Set Up Environment Variables for Supabase",
|
||||
"description": "Configure environment variables required for Supabase connection.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"details": "Create `.env` files for development and production with necessary Supabase configurations. Use `dotenv` package to load them.\n\n**What was implemented:**\n1. **Environment Variables Configuration**:\n - ✅ Supabase environment variables are already properly configured in `.env`\n - ✅ Created comprehensive `.env.example` file with all required variables\n - ✅ Enhanced `src/lib/supabase.ts` with better validation and error handling\n2. **Enhanced Supabase Configuration**:\n - ✅ Added environment variable validation with specific error messages\n - ✅ Added URL format validation for Supabase URL\n - ✅ Created `testSupabaseConnection()` function for connection testing\n - ✅ Added `validateEnvironment()` function to check required variables\n3. **Environment Validation Component**:\n - ✅ Created `EnvironmentValidator` component for UI-based environment checking\n - ✅ Includes connection testing functionality\n - ✅ Provides setup instructions for missing variables\n - ✅ Shows detailed status of environment configuration\n4. **Key Environment Variables Configured**:\n - ✅ `NEXT_PUBLIC_SUPABASE_URL`: https://revhjskovnhnmmuorjzs.supabase.co\n - ✅ `NEXT_PUBLIC_SUPABASE_ANON_KEY`: Properly configured\n - ✅ `SUPABASE_SERVICE_ROLE_KEY`: Available for server-side operations",
|
||||
"testStrategy": "Verify that the application can connect to Supabase using environment variables.\n\n**Verification**:\n- ✅ Build process completes successfully with environment variables loaded\n- ✅ Supabase client initializes without errors\n- ✅ Environment validation functions work correctly\n- ✅ Connection testing functionality implemented",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Configure Supabase environment variables in `.env`",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Create comprehensive `.env.example` file with all required variables",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Enhance `src/lib/supabase.ts` with better validation and error handling",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Add environment variable validation with specific error messages",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Add URL format validation for Supabase URL",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Create `testSupabaseConnection()` function for connection testing",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Add `validateEnvironment()` function to check required variables",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Create `EnvironmentValidator` component for UI-based environment checking",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Include connection testing functionality in EnvironmentValidator",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Provide setup instructions for missing variables in EnvironmentValidator",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Show detailed status of environment configuration in EnvironmentValidator",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Add Error Boundaries Around Dashboard Components",
|
||||
"description": "Implement error boundaries to catch and display errors in dashboard components.",
|
||||
"details": "Use React's `ErrorBoundary` component or a custom implementation. Wrap dashboard components with the error boundary.",
|
||||
"testStrategy": "Simulate errors in dashboard components and verify that they are caught by the error boundaries.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
12
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"title": "Implement Retry Mechanisms for Failed Data Loads",
|
||||
"description": "Add retry functionality for data loading operations that fail.",
|
||||
"details": "Use a library like `retry` or implement custom retry logic with exponential backoff. Integrate with data fetching functions.",
|
||||
"testStrategy": "Simulate network failures and verify that the application retries data loading operations.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
12
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Create Error Logging Utility Functions",
|
||||
"description": "Develop utility functions for consistent error logging across the application.",
|
||||
"details": "Create a centralized error logging function that handles serialization and sends logs to a monitoring service if needed.",
|
||||
"testStrategy": "Verify that errors are logged consistently using the utility functions.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
12
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"title": "Implement User-Friendly Error Messages",
|
||||
"description": "Display user-friendly error messages when data loading fails or other errors occur.",
|
||||
"details": "Update UI components to show meaningful error messages. Use a library like `react-toastify` for notifications.",
|
||||
"testStrategy": "Simulate errors and verify that user-friendly error messages are displayed.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
13,
|
||||
15
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"title": "Add Loading States for Data Operations",
|
||||
"description": "Implement loading states to indicate data operations in progress.",
|
||||
"details": "Use a library like `react-loader-spinner` or create custom loading indicators. Integrate with data fetching functions.",
|
||||
"testStrategy": "Verify that loading states are displayed during data operations.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
12
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"title": "Implement Graceful Degradation for Database Unavailability",
|
||||
"description": "Handle database connection issues gracefully and provide fallback UI.",
|
||||
"details": "Add health checks for the Supabase connection. Implement fallback UI components to display when data is unavailable.",
|
||||
"testStrategy": "Simulate database unavailability and verify that the application handles it gracefully with fallback UI.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
12,
|
||||
13
|
||||
],
|
||||
"status": "pending",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"title": "Fix Browser Error Serialization",
|
||||
"description": "Ensure errors display detailed information instead of 'Object' in browser environment.",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
1,
|
||||
13,
|
||||
15
|
||||
],
|
||||
"priority": "high",
|
||||
"details": "Investigate why errors are showing as 'Object' in the browser console. Implement proper serialization for error objects to display meaningful information. This may involve updating error handling code and ensuring all relevant error properties are included in the serialized output.\n\n**What was implemented:**\n1. **Enhanced Error Serialization** (`src/utils/errorUtils.ts`):\n - ✅ Improved `serializeError()` function to better handle browser-side errors\n - ✅ Added specific handling for Supabase PostgrestError objects\n - ✅ Enhanced error extraction for objects that might be error-like\n - ✅ Added fallback serialization with better error type detection\n2. **Improved Error Logging**:\n - ✅ Enhanced `logError()` function with better error extraction\n - ✅ Added error type detection and additional debugging info\n - ✅ Improved handling of non-serializable objects\n - ✅ Added development-mode debugging with error keys and prototype info\n3. **Supabase Error Handling**:\n - ✅ Created `extractSupabaseErrorInfo()` function for detailed Supabase error extraction\n - ✅ Added specific handling for PostgrestError, details, hints, and codes\n - ✅ Enhanced error context with Supabase-specific information\n4. **Updated All Dashboard Pages**:\n - ✅ **Performance page**: Enhanced error handling with Supabase error extraction\n - ✅ **SEO page**: Improved error logging with detailed context\n - ✅ **Monitoring page**: Added Supabase error info to error logs\n - ✅ **Alerts page**: Enhanced error context with detailed error information",
|
||||
"testStrategy": "Simulate various error scenarios in the application and verify that detailed error information is displayed in the browser console instead of 'Object'. Test across different browsers to ensure consistency.",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"title": "Set Up Missing Database Tables",
|
||||
"description": "Create required database tables (scans, scan_results, pages, alerts) in Supabase to support dashboard functionality and resolve 400 errors.",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
9,
|
||||
18
|
||||
],
|
||||
"priority": "high",
|
||||
"details": "1. Identify missing tables based on application requirements.\n2. Define schema for each table including columns and data types.\n3. Use Supabase SQL editor or API to create the tables.\n4. Verify table creation and structure using Supabase tools.\n5. Update any necessary database migration scripts.\n6. Create comprehensive database setup script (setup-database.sql) with complete schema, relationships, constraints, sample data, RLS policies, and performance indexes.\n7. Enhance DatabaseSetupHelper component to check for specific missing tables causing 400 errors, add 'Copy SQL' functionality, provide clear instructions, and visual indicators.",
|
||||
"testStrategy": "1. Check that all required tables exist in Supabase.\n2. Verify table schemas match requirements.\n3. Test dashboard functionality to ensure it can read/write data from/to the new tables.\n4. Run any existing database tests to confirm integration.\n5. Verify that 400 errors for scans, websites, and alerts are resolved after running the database setup script.\n6. Confirm that dashboard pages load successfully with sample data available for testing.",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Identify missing tables based on application requirements",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Define schema for each table including columns and data types",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Use Supabase SQL editor or API to create the tables",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Verify table creation and structure using Supabase tools",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Update any necessary database migration scripts",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Create comprehensive database setup script (setup-database.sql)",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Enhance DatabaseSetupHelper component",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dependencies": [],
|
||||
"details": "",
|
||||
"testStrategy": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"title": "Build Automatic Lighthouse Scanner System",
|
||||
"description": "Develop a system for automatic Lighthouse scanning with change detection and periodic scanning based on subscription tiers.",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
18,
|
||||
20
|
||||
],
|
||||
"priority": "high",
|
||||
"details": "✅ **Task Completed Successfully**\n\n**Automatic Lighthouse Scanner System Implemented**\n\n**Core Components Created:**\n\n1. **LighthouseScanner Service** (`src/services/lighthouseScanner.ts`):\n - ✅ Change detection using content hash comparison\n - ✅ Subscription-based rate limiting and limits checking\n - ✅ Comprehensive scan execution with metrics collection\n - ✅ Support for multiple device types and scan categories\n - ✅ Automatic scan result storage and metric tracking\n\n2. **ScanScheduler Service** (`src/services/scanScheduler.ts`):\n - ✅ Periodic scan scheduling based on frequency settings\n - ✅ Change detection processing for all websites\n - ✅ Subscription tier validation and feature availability\n - ✅ Next run time calculation and schedule management\n\n3. **Enhanced Cron Handler** (`src/app/api/cron/scan/route.ts`):\n - ✅ Orchestrates both scheduled and change detection scans\n - ✅ Manual scan triggering with subscription validation\n - ✅ Comprehensive scan statistics and monitoring\n - ✅ Error handling and logging for all scan operations\n\n4. **Webhook Handler** (`src/app/api/webhooks/website-change/route.ts`):\n - ✅ External change detection webhook endpoint\n - ✅ Automatic scan triggering on website changes\n - ✅ Subscription validation and rate limiting\n - ✅ Audit logging for change detection events\n\n5. **ScanScheduleManager Component** (`src/components/dashboard/ScanScheduleManager.tsx`):\n - ✅ User interface for managing scan schedules\n - ✅ Subscription tier display and feature availability\n - ✅ Usage tracking and limit monitoring\n - ✅ Manual scan triggering with validation\n\n**Key Features Implemented:**\n\n**Subscription-Based Features:**\n- **Free Tier**: 5 scans/day, 50/month, no change detection, no scheduled scans\n- **Starter Tier**: 20 scans/day, 200/month, change detection enabled, daily scheduled scans\n- **Professional Tier**: 100 scans/day, 1000/month, change detection enabled, hourly scheduled scans\n- **Enterprise Tier**: 500 scans/day, 5000/month, change detection enabled, hourly scheduled scans\n\n**Change Detection:**\n- Content hash comparison to detect website changes\n- Automatic high-priority scans when changes are detected\n- Webhook support for external change notifications\n- Audit logging for all change detection events\n\n**Scheduled Scanning:**\n- Configurable frequencies: hourly, daily, weekly, monthly\n- Device type selection: desktop, mobile, or both\n- Category selection: performance, accessibility, SEO, best practices\n- Automatic schedule management and next run time calculation\n\n**Rate Limiting & Monitoring:**\n- Daily and monthly scan limits per subscription tier\n- Real-time usage tracking and limit enforcement\n- Comprehensive scan statistics and reporting\n- Error handling and logging for all operations\n\n**Integration Points:**\n- Seamless integration with existing dashboard components\n- Database schema compatibility with existing tables\n- API endpoints for external integrations\n- Webhook support for third-party change detection\n\n**Usage Instructions:**\n1. **Setup Cron Jobs**: Configure cron to call `/api/cron/scan` at desired intervals\n2. **Configure Webhooks**: Set up webhook endpoints for external change detection\n3. **Manage Schedules**: Use the ScanScheduleManager component in the dashboard\n4. **Monitor Usage**: Track scan usage and limits through the dashboard interface\n\nThe system is now ready for production use with full subscription-based feature control and automatic scanning capabilities.",
|
||||
"testStrategy": "1. Verify that change detection triggers scans accurately when website content changes.\n2. Test periodic scanning schedules for different subscription tiers.\n3. Check that scan results are stored correctly in the Supabase database.\n4. Ensure error handling works as expected with meaningful error messages.\n5. Validate UI components display scan results and statuses correctly.\n6. Confirm notifications are sent for scan completions and critical issues.",
|
||||
"subtasks": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created": "2025-07-24T14:16:43.871Z",
|
||||
"updated": "2025-08-04T10:51:41.627Z",
|
||||
"description": "Tasks for master context"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<context>
|
||||
# Overview
|
||||
[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.]
|
||||
|
||||
# Core Features
|
||||
[List and describe the main features of your product. For each feature, include:
|
||||
- What it does
|
||||
- Why it's important
|
||||
- How it works at a high level]
|
||||
|
||||
# User Experience
|
||||
[Describe the user journey and experience. Include:
|
||||
- User personas
|
||||
- Key user flows
|
||||
- UI/UX considerations]
|
||||
</context>
|
||||
<PRD>
|
||||
# Technical Architecture
|
||||
[Outline the technical implementation details:
|
||||
- System components
|
||||
- Data models
|
||||
- APIs and integrations
|
||||
- Infrastructure requirements]
|
||||
|
||||
# Development Roadmap
|
||||
[Break down the development process into phases:
|
||||
- MVP requirements
|
||||
- Future enhancements
|
||||
- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks]
|
||||
|
||||
# Logical Dependency Chain
|
||||
[Define the logical order of development:
|
||||
- Which features need to be built first (foundation)
|
||||
- Getting as quickly as possible to something usable/visible front end that works
|
||||
- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches]
|
||||
|
||||
# Risks and Mitigations
|
||||
[Identify potential risks and how they'll be addressed:
|
||||
- Technical challenges
|
||||
- Figuring out the MVP that we can build upon
|
||||
- Resource constraints]
|
||||
|
||||
# Appendix
|
||||
[Include any additional information:
|
||||
- Research findings
|
||||
- Technical specifications]
|
||||
</PRD>
|
||||
@@ -0,0 +1,68 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
- `npm run dev` - Start Next.js development server with Turbopack
|
||||
- `npm run build` - Build the application for production
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint for code quality checks
|
||||
- `npm run dev:all` - Start both frontend and backend servers concurrently
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a website monitoring platform built with Next.js 15 (App Router) that analyzes website performance, SEO, and accessibility using Lighthouse scans.
|
||||
|
||||
### Core Technologies
|
||||
- **Frontend**: Next.js 15 with App Router, React 19, TypeScript
|
||||
- **UI**: Tailwind CSS with Shadcn/UI components
|
||||
- **Database**: Supabase (PostgreSQL)
|
||||
- **Authentication**: Supabase Auth
|
||||
- **Charts**: Recharts and Chart.js
|
||||
- **Web Scraping**: Puppeteer and Lighthouse integration
|
||||
- **Backend Worker**: Dockerized Express server for scan processing
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `src/app/` - Next.js App Router pages and API routes
|
||||
- `api/` - Backend API endpoints for scanning, crawling, analysis
|
||||
- `dashboard/` - Main dashboard pages and website management
|
||||
- `auth/` - Authentication pages
|
||||
- `src/components/` - React components organized by feature
|
||||
- `core/` - Core dashboard and competitor analysis components
|
||||
- `dashboard/` - Website monitoring and metrics components
|
||||
- `ui/` - Reusable UI components (forms, feedback, data display)
|
||||
- `src/contexts/` - React contexts for auth and monitoring state
|
||||
- `src/services/` - Business logic services for API calls
|
||||
- `src/types/` - TypeScript type definitions
|
||||
- `scanner-worker/` - Dockerized Express worker for Lighthouse scans
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Authentication Flow**:
|
||||
- Uses Supabase auth with custom AuthContext (`src/contexts/AuthContext.tsx`)
|
||||
- Users are automatically assigned to organizations
|
||||
- Route protection implemented in AuthContext
|
||||
|
||||
**Data Flow**:
|
||||
- API routes in `src/app/api/` handle backend logic
|
||||
- Services in `src/services/` manage API calls from frontend
|
||||
- Supabase client configured in `src/lib/supabase.ts`
|
||||
|
||||
**Component Structure**:
|
||||
- UI components use Shadcn/UI patterns with Tailwind CSS
|
||||
- Dashboard components are organized by feature (dashboard/, monitoring/, etc.)
|
||||
- Reusable components in `ui/` folder with consistent interfaces
|
||||
|
||||
**Scanning Architecture**:
|
||||
- Frontend triggers scans via API routes
|
||||
- Docker worker processes Lighthouse scans with Puppeteer
|
||||
- Results stored in Supabase and displayed in dashboard
|
||||
|
||||
### Development Notes
|
||||
|
||||
- Uses Supabase for both authentication and database
|
||||
- Scanner worker runs in Docker with Chromium for consistent Lighthouse results
|
||||
- Components follow Shadcn/UI patterns and use class-variance-authority for styling
|
||||
- Form validation uses react-hook-form with Zod schemas
|
||||
@@ -0,0 +1,38 @@
|
||||
# --- Stage 1: Dependencies ---
|
||||
FROM node:20-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# --- Stage 2: Build ---
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TAILWIND_DISABLE_OXIDE=1
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 3: Production ---
|
||||
FROM node:20-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN groupadd -r app && useradd -r -g app -d /app app
|
||||
|
||||
COPY --from=builder --chown=app:app /app/.next/standalone ./
|
||||
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=app:app /app/public ./public
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const h=require('http');h.get('http://localhost:3000/api/health',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Website Monitoring Platform
|
||||
|
||||
This project is a modern website monitoring platform built with Next.js (App Router) for the frontend and a Dockerized Express-based Lighthouse scan worker for performance, SEO, and accessibility analysis.
|
||||
|
||||
## Features
|
||||
|
||||
- Add and manage websites in a dashboard
|
||||
- Trigger Lighthouse scans for any website via a button in the dashboard
|
||||
- View scan results directly in the frontend
|
||||
- Local development with Docker for the scan worker (Chromium included)
|
||||
- Modular architecture for future automation, cron jobs, and database integration
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (for the frontend)
|
||||
- [Docker](https://www.docker.com/) (for the scan worker)
|
||||
- [npm](https://www.npmjs.com/) or [pnpm](https://pnpm.io/) (for dependency management)
|
||||
|
||||
---
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Start the Lighthouse Scan Worker (Docker)
|
||||
|
||||
Build and run the scan worker container (from the project root):
|
||||
|
||||
```bash
|
||||
docker-compose up --build scan-worker
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Build the worker image (installs Node.js dependencies and Chromium)
|
||||
- Start the Express server on port 5001 inside the container
|
||||
|
||||
### 3. Start the Next.js Frontend
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Add new columns for improved crawl progress tracking
|
||||
ALTER TABLE crawl_sessions
|
||||
ADD COLUMN IF NOT EXISTS total_urls INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS processed_urls INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS progress_percentage INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS current_url TEXT;
|
||||
|
||||
-- Update existing sessions to use new columns (map old data)
|
||||
UPDATE crawl_sessions
|
||||
SET
|
||||
total_urls = pages_discovered,
|
||||
processed_urls = pages_processed,
|
||||
progress_percentage = CASE
|
||||
WHEN pages_discovered > 0 THEN ROUND((pages_processed::float / pages_discovered::float) * 100)
|
||||
ELSE 0
|
||||
END
|
||||
WHERE total_urls IS NULL OR total_urls = 0;
|
||||
|
||||
-- Add index for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_sessions_status ON crawl_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_sessions_website_status ON crawl_sessions(website_id, status);
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Database Fixes for Website Monitoring Frontend
|
||||
-- Run this in your Supabase SQL editor to fix the console errors
|
||||
|
||||
-- 1. Add missing columns to crawl_sessions table that the API expects
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS total_urls INTEGER DEFAULT 0;
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS processed_urls INTEGER DEFAULT 0;
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS progress_percentage INTEGER DEFAULT 0;
|
||||
|
||||
-- 2. Ensure all required columns exist in crawl_sessions table
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS pages_discovered INTEGER DEFAULT 0;
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS pages_processed INTEGER DEFAULT 0;
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS current_url VARCHAR;
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||
ALTER TABLE crawl_sessions ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb;
|
||||
|
||||
-- 3. Add missing columns to users table if they don't exist
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR DEFAULT 'member';
|
||||
|
||||
-- 4. Add missing columns to organizations table if they don't exist
|
||||
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS api_key TEXT;
|
||||
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS max_websites INTEGER DEFAULT 10;
|
||||
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS max_scans_per_month INTEGER DEFAULT 1000;
|
||||
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS subscription_status VARCHAR DEFAULT 'active';
|
||||
|
||||
-- 5. Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_sessions_status ON crawl_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_sessions_website_status ON crawl_sessions(website_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_organization_id ON users(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_websites_organization_id ON websites(organization_id);
|
||||
|
||||
-- 6. Ensure RLS policies exist for crawl_sessions
|
||||
-- Enable RLS if not already enabled
|
||||
ALTER TABLE crawl_sessions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create basic RLS policy for crawl_sessions if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'crawl_sessions'
|
||||
AND policyname = 'Allow read for authenticated users'
|
||||
) THEN
|
||||
CREATE POLICY "Allow read for authenticated users" ON crawl_sessions
|
||||
FOR SELECT USING (true);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'crawl_sessions'
|
||||
AND policyname = 'Allow insert for authenticated users'
|
||||
) THEN
|
||||
CREATE POLICY "Allow insert for authenticated users" ON crawl_sessions
|
||||
FOR INSERT WITH CHECK (true);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'crawl_sessions'
|
||||
AND policyname = 'Allow update for authenticated users'
|
||||
) THEN
|
||||
CREATE POLICY "Allow update for authenticated users" ON crawl_sessions
|
||||
FOR UPDATE USING (true);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 7. Refresh Supabase schema cache to pick up new columns
|
||||
-- This is important to resolve "Could not find column in schema cache" errors
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
-- 8. Verify the fixes by checking table structure
|
||||
-- You can run these queries to verify the fixes worked:
|
||||
-- SELECT column_name, data_type, is_nullable, column_default
|
||||
-- FROM information_schema.columns
|
||||
-- WHERE table_name = 'crawl_sessions'
|
||||
-- ORDER BY ordinal_position;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
-- Required tables for the website monitoring application
|
||||
-- Run these in your Supabase SQL editor to create missing tables
|
||||
|
||||
-- Team invitations table
|
||||
CREATE TABLE IF NOT EXISTS team_invitations (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
role text CHECK (role IN ('admin', 'member')) NOT NULL DEFAULT 'member',
|
||||
organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
invited_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
status text CHECK (status IN ('pending', 'accepted', 'expired')) NOT NULL DEFAULT 'pending',
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
expires_at timestamp with time zone DEFAULT (now() + interval '7 days')
|
||||
);
|
||||
|
||||
-- User notification preferences table
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
|
||||
email_notifications boolean DEFAULT true,
|
||||
sms_notifications boolean DEFAULT false,
|
||||
browser_notifications boolean DEFAULT true,
|
||||
weekly_report boolean DEFAULT true,
|
||||
timezone text DEFAULT 'UTC',
|
||||
date_format text DEFAULT 'MM/DD/YYYY',
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
-- Alerts table
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
website_id uuid REFERENCES websites(id) ON DELETE CASCADE,
|
||||
type text CHECK (type IN ('downtime', 'performance', 'error', 'ssl', 'maintenance')) NOT NULL,
|
||||
severity text CHECK (severity IN ('low', 'medium', 'high', 'critical')) NOT NULL DEFAULT 'medium',
|
||||
title text NOT NULL,
|
||||
message text NOT NULL,
|
||||
status text CHECK (status IN ('active', 'resolved', 'acknowledged')) NOT NULL DEFAULT 'active',
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
resolved_at timestamp with time zone,
|
||||
acknowledged_at timestamp with time zone
|
||||
);
|
||||
|
||||
-- Alert rules table
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
type text CHECK (type IN ('downtime', 'performance', 'error_rate')) NOT NULL,
|
||||
condition text NOT NULL,
|
||||
threshold numeric NOT NULL,
|
||||
enabled boolean DEFAULT true,
|
||||
notification_methods text[] DEFAULT ARRAY['email'],
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
-- Uptime checks table
|
||||
CREATE TABLE IF NOT EXISTS uptime_checks (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
website_id uuid REFERENCES websites(id) ON DELETE CASCADE,
|
||||
status text CHECK (status IN ('up', 'down', 'warning')) NOT NULL,
|
||||
response_time integer, -- in milliseconds
|
||||
status_code integer,
|
||||
error_message text,
|
||||
checked_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add missing columns to existing tables if they don't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Add API key to organizations table
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'organizations' AND column_name = 'api_key') THEN
|
||||
ALTER TABLE organizations ADD COLUMN api_key text;
|
||||
END IF;
|
||||
|
||||
-- Add max limits to organizations table
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'organizations' AND column_name = 'max_websites') THEN
|
||||
ALTER TABLE organizations ADD COLUMN max_websites integer DEFAULT 10;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'organizations' AND column_name = 'max_scans_per_month') THEN
|
||||
ALTER TABLE organizations ADD COLUMN max_scans_per_month integer DEFAULT 1000;
|
||||
END IF;
|
||||
|
||||
-- Add created_at to users table if missing
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'created_at') THEN
|
||||
ALTER TABLE users ADD COLUMN created_at timestamp with time zone DEFAULT now();
|
||||
END IF;
|
||||
|
||||
-- Add last_login_at to users table if missing
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'last_login_at') THEN
|
||||
ALTER TABLE users ADD COLUMN last_login_at timestamp with time zone;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_organization_id ON team_invitations(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_email ON team_invitations(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_website_id ON alerts(website_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rules_organization_id ON alert_rules(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_checks_website_id ON uptime_checks(website_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_checks_checked_at ON uptime_checks(checked_at);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE team_invitations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_notification_preferences ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE alerts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE alert_rules ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE uptime_checks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create RLS policies
|
||||
-- Team invitations policies
|
||||
CREATE POLICY "Users can view invitations for their organization" ON team_invitations
|
||||
FOR SELECT USING (
|
||||
organization_id IN (
|
||||
SELECT organization_id FROM users WHERE id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins and owners can manage invitations" ON team_invitations
|
||||
FOR ALL USING (
|
||||
organization_id IN (
|
||||
SELECT organization_id FROM users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- User notification preferences policies
|
||||
CREATE POLICY "Users can manage their own preferences" ON user_notification_preferences
|
||||
FOR ALL USING (user_id = auth.uid());
|
||||
|
||||
-- Alerts policies
|
||||
CREATE POLICY "Users can view alerts for their organization's websites" ON alerts
|
||||
FOR SELECT USING (
|
||||
website_id IN (
|
||||
SELECT w.id FROM websites w
|
||||
JOIN users u ON w.organization_id = u.organization_id
|
||||
WHERE u.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can update alerts for their organization's websites" ON alerts
|
||||
FOR UPDATE USING (
|
||||
website_id IN (
|
||||
SELECT w.id FROM websites w
|
||||
JOIN users u ON w.organization_id = u.organization_id
|
||||
WHERE u.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Alert rules policies
|
||||
CREATE POLICY "Users can manage alert rules for their organization" ON alert_rules
|
||||
FOR ALL USING (
|
||||
organization_id IN (
|
||||
SELECT organization_id FROM users WHERE id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Uptime checks policies
|
||||
CREATE POLICY "Users can view uptime checks for their organization's websites" ON uptime_checks
|
||||
FOR SELECT USING (
|
||||
website_id IN (
|
||||
SELECT w.id FROM websites w
|
||||
JOIN users u ON w.organization_id = u.organization_id
|
||||
WHERE u.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Competitor metrics table
|
||||
CREATE TABLE IF NOT EXISTS competitor_metrics (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
website_id uuid REFERENCES websites(id) ON DELETE CASCADE,
|
||||
url text NOT NULL,
|
||||
name text,
|
||||
performance_score numeric,
|
||||
seo_score numeric,
|
||||
accessibility_score numeric,
|
||||
best_practices_score numeric,
|
||||
status_code integer,
|
||||
response_time integer,
|
||||
last_scanned_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
UNIQUE(website_id, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_competitor_metrics_website_id ON competitor_metrics(website_id);
|
||||
|
||||
-- Alert configurations table (per-website thresholds)
|
||||
CREATE TABLE IF NOT EXISTS alert_configurations (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
website_id uuid REFERENCES websites(id) ON DELETE CASCADE UNIQUE,
|
||||
performance_threshold numeric DEFAULT 0.5,
|
||||
seo_threshold numeric DEFAULT 0.5,
|
||||
accessibility_threshold numeric DEFAULT 0.5,
|
||||
uptime_threshold numeric DEFAULT 0.95,
|
||||
email_enabled boolean DEFAULT true,
|
||||
email_address text,
|
||||
slack_enabled boolean DEFAULT false,
|
||||
slack_webhook_url text,
|
||||
alert_frequency text DEFAULT 'immediate',
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add template_hash to pages table for layout deduplication
|
||||
ALTER TABLE pages ADD COLUMN IF NOT EXISTS template_hash VARCHAR;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_template_hash ON pages(template_hash) WHERE template_hash IS NOT NULL;
|
||||
);
|
||||
@@ -0,0 +1,994 @@
|
||||
-- Enable necessary extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
------ ENUMS ------
|
||||
-- Core enums for status and types
|
||||
CREATE TYPE scan_status AS ENUM (
|
||||
'pending',
|
||||
'queued',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
);
|
||||
|
||||
CREATE TYPE severity_level AS ENUM (
|
||||
'critical',
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
'info'
|
||||
);
|
||||
|
||||
CREATE TYPE comparison_operator AS ENUM (
|
||||
'less_than',
|
||||
'less_than_equal',
|
||||
'greater_than',
|
||||
'greater_than_equal',
|
||||
'equal_to',
|
||||
'not_equal_to'
|
||||
);
|
||||
|
||||
CREATE TYPE metric_category AS ENUM (
|
||||
'performance',
|
||||
'seo',
|
||||
'accessibility',
|
||||
'best_practices',
|
||||
'security',
|
||||
'pwa'
|
||||
);
|
||||
|
||||
CREATE TYPE resource_type AS ENUM (
|
||||
'script',
|
||||
'stylesheet',
|
||||
'image',
|
||||
'font',
|
||||
'document',
|
||||
'media',
|
||||
'other'
|
||||
);
|
||||
|
||||
CREATE TYPE notification_channel AS ENUM (
|
||||
'email',
|
||||
'slack',
|
||||
'webhook',
|
||||
'in_app'
|
||||
);
|
||||
|
||||
CREATE TYPE subscription_tier AS ENUM (
|
||||
'free',
|
||||
'starter',
|
||||
'professional',
|
||||
'enterprise'
|
||||
);
|
||||
|
||||
CREATE TYPE user_role AS ENUM (
|
||||
'owner',
|
||||
'admin',
|
||||
'editor',
|
||||
'viewer'
|
||||
);
|
||||
|
||||
------ CORE TABLES ------
|
||||
-- Organizations table
|
||||
CREATE TABLE organizations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR NOT NULL,
|
||||
subscription_tier subscription_tier DEFAULT 'free',
|
||||
subscription_status VARCHAR DEFAULT 'active',
|
||||
billing_email VARCHAR,
|
||||
max_websites INTEGER DEFAULT 5,
|
||||
max_users INTEGER DEFAULT 3,
|
||||
scan_frequency_minutes INTEGER DEFAULT 60,
|
||||
settings JSONB DEFAULT '{
|
||||
"alert_email_digest": "daily",
|
||||
"default_scan_depth": 3,
|
||||
"retention_days": 90,
|
||||
"enable_competitor_analysis": false
|
||||
}'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email VARCHAR UNIQUE NOT NULL,
|
||||
name VARCHAR,
|
||||
organization_id UUID REFERENCES organizations(id),
|
||||
role user_role DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
settings JSONB DEFAULT '{
|
||||
"email_notifications": true,
|
||||
"notification_frequency": "instant",
|
||||
"dashboard_layout": "default"
|
||||
}'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Websites table
|
||||
CREATE TABLE websites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) NOT NULL,
|
||||
base_url VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
crawl_settings JSONB DEFAULT '{
|
||||
"max_pages": 100,
|
||||
"max_depth": 3,
|
||||
"exclude_patterns": [
|
||||
"/admin/*",
|
||||
"/api/*",
|
||||
"*.pdf",
|
||||
"*.jpg",
|
||||
"*.png"
|
||||
],
|
||||
"include_patterns": ["/*"],
|
||||
"respect_robots_txt": true,
|
||||
"crawl_frequency": "daily",
|
||||
"crawl_timing": "off_peak"
|
||||
}'::jsonb,
|
||||
scan_schedule JSONB DEFAULT '{
|
||||
"frequency": "hourly",
|
||||
"time_windows": ["0-6", "20-23"],
|
||||
"days": ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
||||
}'::jsonb,
|
||||
performance_budgets JSONB DEFAULT '{
|
||||
"page_weight_kb": 1000,
|
||||
"max_requests": 100,
|
||||
"time_to_interactive_ms": 3000,
|
||||
"first_contentful_paint_ms": 1000
|
||||
}'::jsonb,
|
||||
notifications JSONB DEFAULT '{
|
||||
"channels": ["email"],
|
||||
"thresholds": {
|
||||
"performance": 90,
|
||||
"accessibility": 90,
|
||||
"seo": 90,
|
||||
"best_practices": 90
|
||||
}
|
||||
}'::jsonb,
|
||||
last_crawl_at TIMESTAMPTZ,
|
||||
last_scan_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(organization_id, base_url)
|
||||
);
|
||||
|
||||
-- Competitor tracking
|
||||
CREATE TABLE competitor_websites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
competitor_url VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
scan_frequency VARCHAR DEFAULT 'daily',
|
||||
metrics_to_track VARCHAR[] DEFAULT ARRAY[
|
||||
'performance',
|
||||
'seo',
|
||||
'accessibility',
|
||||
'best_practices'
|
||||
],
|
||||
last_scan_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(website_id, competitor_url)
|
||||
);
|
||||
|
||||
-- Pages table
|
||||
CREATE TABLE pages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
url VARCHAR NOT NULL,
|
||||
path VARCHAR NOT NULL,
|
||||
title VARCHAR,
|
||||
description TEXT,
|
||||
content_hash VARCHAR,
|
||||
content_type VARCHAR,
|
||||
status_code INTEGER,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
priority INTEGER DEFAULT 1,
|
||||
depth INTEGER DEFAULT 0,
|
||||
parent_page_id UUID REFERENCES pages(id),
|
||||
discovery_method VARCHAR DEFAULT 'crawl',
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{
|
||||
"inbound_links": 0,
|
||||
"outbound_links": 0,
|
||||
"word_count": 0,
|
||||
"has_canonical": false,
|
||||
"is_indexable": true
|
||||
}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(website_id, url)
|
||||
);
|
||||
|
||||
------ METRIC DEFINITIONS AND THRESHOLDS ------
|
||||
CREATE TABLE metric_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
key VARCHAR NOT NULL UNIQUE,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category metric_category NOT NULL,
|
||||
unit VARCHAR,
|
||||
is_core_metric BOOLEAN DEFAULT false,
|
||||
default_threshold NUMERIC,
|
||||
warning_threshold NUMERIC,
|
||||
critical_threshold NUMERIC,
|
||||
direction VARCHAR NOT NULL DEFAULT 'higher_is_better',
|
||||
weight NUMERIC DEFAULT 1.0,
|
||||
documentation_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Populate core metrics
|
||||
INSERT INTO metric_definitions
|
||||
(key, name, description, category, unit, is_core_metric, default_threshold, warning_threshold, critical_threshold, direction)
|
||||
VALUES
|
||||
-- Core Web Vitals
|
||||
('performance', 'Performance Score', 'Overall performance score of the website', 'performance', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
('accessibility', 'Accessibility Score', 'Overall accessibility score of the website', 'accessibility', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
('seo', 'SEO Score', 'Overall SEO score of the website', 'seo', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
('bestPractices', 'Best Practices Score', 'Overall best practices score', 'best_practices', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
|
||||
-- Performance Metrics
|
||||
('firstContentfulPaint', 'First Contentful Paint', 'Time when the first text or image is painted', 'performance', 'ms', true, 1800, 2500, 4000, 'lower_is_better'),
|
||||
('largestContentfulPaint', 'Largest Contentful Paint', 'Time when the largest text or image is painted', 'performance', 'ms', true, 2500, 4000, 6000, 'lower_is_better'),
|
||||
('totalBlockingTime', 'Total Blocking Time', 'Sum of all time periods between FCP and Time to Interactive', 'performance', 'ms', true, 200, 400, 600, 'lower_is_better'),
|
||||
('cumulativeLayoutShift', 'Cumulative Layout Shift', 'Measures visual stability', 'performance', 'score', true, 0.1, 0.25, 0.4, 'lower_is_better'),
|
||||
('speedIndex', 'Speed Index', 'How quickly content is visually displayed', 'performance', 'ms', true, 3400, 5800, 8800, 'lower_is_better'),
|
||||
('interactive', 'Time to Interactive', 'Time to fully interactive', 'performance', 'ms', true, 3800, 7300, 12700, 'lower_is_better'),
|
||||
|
||||
-- Resource Metrics
|
||||
('totalByteWeight', 'Total Byte Weight', 'Total size of all resources', 'performance', 'bytes', false, 1600000, 2400000, 3200000, 'lower_is_better'),
|
||||
('serverResponseTime', 'Server Response Time', 'Time for server to respond to main document request', 'performance', 'ms', false, 100, 200, 400, 'lower_is_better'),
|
||||
('networkRtt', 'Network Round Trip Time', 'Network round trip time', 'performance', 'ms', false, 40, 100, 150, 'lower_is_better'),
|
||||
('networkServerLatency', 'Network Server Latency', 'Server latency in network requests', 'performance', 'ms', false, 30, 100, 150, 'lower_is_better');
|
||||
|
||||
------ SCANS AND RESULTS ------
|
||||
-- Scans table
|
||||
CREATE TABLE scans (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
page_id UUID REFERENCES pages(id) NOT NULL,
|
||||
triggered_by UUID REFERENCES users(id),
|
||||
scan_type VARCHAR NOT NULL DEFAULT 'full',
|
||||
status scan_status DEFAULT 'pending',
|
||||
priority INTEGER DEFAULT 1,
|
||||
categories metric_category[] DEFAULT ARRAY['performance', 'seo', 'accessibility', 'best_practices'],
|
||||
device_type VARCHAR DEFAULT 'desktop',
|
||||
user_agent VARCHAR,
|
||||
lighthouse_version VARCHAR,
|
||||
chrome_version VARCHAR,
|
||||
environment JSONB DEFAULT '{}'::jsonb,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Scan results
|
||||
CREATE TABLE scan_results (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
category metric_category NOT NULL,
|
||||
score NUMERIC,
|
||||
raw_data JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Metric values
|
||||
CREATE TABLE metric_values (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
metric_id UUID REFERENCES metric_definitions(id) NOT NULL,
|
||||
value NUMERIC NOT NULL,
|
||||
raw_value VARCHAR,
|
||||
unit VARCHAR,
|
||||
is_passing BOOLEAN,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Resource analysis
|
||||
CREATE TABLE resource_analysis (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
resource_type resource_type NOT NULL,
|
||||
url VARCHAR NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
transfer_size_bytes INTEGER,
|
||||
duration_ms INTEGER,
|
||||
is_third_party BOOLEAN DEFAULT false,
|
||||
is_cached BOOLEAN,
|
||||
compression_ratio NUMERIC,
|
||||
mime_type VARCHAR,
|
||||
protocol VARCHAR,
|
||||
priority VARCHAR,
|
||||
status_code INTEGER,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ MONITORING AND ALERTS ------
|
||||
-- Alert configurations
|
||||
CREATE TABLE alert_configurations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
metric_id UUID REFERENCES metric_definitions(id) NOT NULL,
|
||||
threshold NUMERIC NOT NULL,
|
||||
comparison comparison_operator DEFAULT 'less_than',
|
||||
severity severity_level DEFAULT 'medium',
|
||||
consecutive_count INTEGER DEFAULT 1,
|
||||
cooldown_minutes INTEGER DEFAULT 60,
|
||||
notification_channels notification_channel[] DEFAULT ARRAY['email'],
|
||||
notification_template TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_triggered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alerts
|
||||
CREATE TABLE alerts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
page_id UUID REFERENCES pages(id),
|
||||
config_id UUID REFERENCES alert_configurations(id),
|
||||
metric_id UUID REFERENCES metric_definitions(id),
|
||||
severity severity_level DEFAULT 'medium',
|
||||
title VARCHAR NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
details JSONB DEFAULT '{}'::jsonb,
|
||||
status VARCHAR DEFAULT 'open',
|
||||
acknowledged_by UUID REFERENCES users(id),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alert history
|
||||
CREATE TABLE alert_history (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
alert_id UUID REFERENCES alerts(id) NOT NULL,
|
||||
event_type VARCHAR NOT NULL,
|
||||
event_data JSONB NOT NULL,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Notification delivery
|
||||
CREATE TABLE notification_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
alert_id UUID REFERENCES alerts(id) NOT NULL,
|
||||
channel notification_channel NOT NULL,
|
||||
recipient VARCHAR NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status VARCHAR DEFAULT 'pending',
|
||||
sent_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ CRAWL MANAGEMENT ------
|
||||
-- Crawl queue
|
||||
CREATE TABLE crawl_queue (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
url VARCHAR NOT NULL,
|
||||
priority INTEGER DEFAULT 1,
|
||||
status VARCHAR DEFAULT 'pending',
|
||||
parent_url VARCHAR,
|
||||
discovery_depth INTEGER DEFAULT 0,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
next_attempt_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Crawl sessions
|
||||
CREATE TABLE crawl_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
status VARCHAR DEFAULT 'running',
|
||||
pages_discovered INTEGER DEFAULT 0,
|
||||
pages_processed INTEGER DEFAULT 0,
|
||||
start_url VARCHAR NOT NULL,
|
||||
max_depth INTEGER,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- URL patterns
|
||||
CREATE TABLE url_patterns (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
pattern VARCHAR NOT NULL,
|
||||
pattern_type VARCHAR NOT NULL, -- 'include' or 'exclude'
|
||||
description TEXT,
|
||||
is_regex BOOLEAN DEFAULT false,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
priority INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ PERFORMANCE BUDGETS ------
|
||||
CREATE TABLE performance_budgets (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
metric_id UUID REFERENCES metric_definitions(id),
|
||||
budget_type VARCHAR NOT NULL, -- 'size', 'timing', 'count'
|
||||
threshold NUMERIC NOT NULL,
|
||||
applies_to JSONB DEFAULT '{
|
||||
"resource_types": ["all"],
|
||||
"paths": ["/*"]
|
||||
}'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Budget violations
|
||||
CREATE TABLE budget_violations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
budget_id UUID REFERENCES performance_budgets(id) NOT NULL,
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
actual_value NUMERIC NOT NULL,
|
||||
threshold_value NUMERIC NOT NULL,
|
||||
percentage_over NUMERIC,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ CUSTOM DASHBOARDS AND REPORTS ------
|
||||
-- Dashboard definitions
|
||||
CREATE TABLE dashboards (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
layout JSONB DEFAULT '[]'::jsonb,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Dashboard widgets
|
||||
CREATE TABLE dashboard_widgets (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
dashboard_id UUID REFERENCES dashboards(id) NOT NULL,
|
||||
widget_type VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
config JSONB NOT NULL,
|
||||
position JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Report templates
|
||||
CREATE TABLE report_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
template_type VARCHAR NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
schedule JSONB DEFAULT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Generated reports
|
||||
CREATE TABLE generated_reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
template_id UUID REFERENCES report_templates(id) NOT NULL,
|
||||
website_id UUID REFERENCES websites(id),
|
||||
generated_by UUID REFERENCES users(id),
|
||||
report_data JSONB NOT NULL,
|
||||
format VARCHAR DEFAULT 'pdf',
|
||||
file_url VARCHAR,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ API ACCESS AND RATE LIMITING ------
|
||||
-- API keys
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
key_hash VARCHAR NOT NULL,
|
||||
scopes VARCHAR[] DEFAULT ARRAY['read'],
|
||||
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Rate limiting
|
||||
CREATE TABLE rate_limits (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
key_type VARCHAR NOT NULL, -- 'api_key', 'ip_address'
|
||||
key_value VARCHAR NOT NULL,
|
||||
window_start TIMESTAMPTZ NOT NULL,
|
||||
request_count INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ AUDIT LOGGING ------
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
action VARCHAR NOT NULL,
|
||||
entity_type VARCHAR NOT NULL,
|
||||
entity_id UUID,
|
||||
changes JSONB,
|
||||
ip_address VARCHAR,
|
||||
user_agent VARCHAR,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ VIEWS ------
|
||||
-- Performance trend view
|
||||
CREATE VIEW performance_trends AS
|
||||
SELECT
|
||||
w.id AS website_id,
|
||||
w.name AS website_name,
|
||||
p.url AS page_url,
|
||||
m.key AS metric_key,
|
||||
mv.value AS metric_value,
|
||||
s.created_at AS scan_date,
|
||||
AVG(mv.value) OVER (
|
||||
PARTITION BY w.id, p.id, m.id
|
||||
ORDER BY s.created_at
|
||||
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
|
||||
) AS rolling_average
|
||||
FROM websites w
|
||||
JOIN pages p ON p.website_id = w.id
|
||||
JOIN scans s ON s.page_id = p.id
|
||||
JOIN metric_values mv ON mv.scan_id = s.id
|
||||
JOIN metric_definitions m ON m.id = mv.metric_id
|
||||
WHERE s.created_at >= NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Resource usage summary view
|
||||
CREATE VIEW resource_usage_summary AS
|
||||
SELECT
|
||||
w.id AS website_id,
|
||||
w.name AS website_name,
|
||||
ra.resource_type,
|
||||
COUNT(*) AS resource_count,
|
||||
AVG(ra.size_bytes) AS avg_size,
|
||||
SUM(ra.size_bytes) AS total_size,
|
||||
AVG(ra.duration_ms) AS avg_duration
|
||||
FROM websites w
|
||||
JOIN scans s ON s.website_id = w.id
|
||||
JOIN resource_analysis ra ON ra.scan_id = s.id
|
||||
WHERE s.created_at >= NOW() - INTERVAL '24 hours'
|
||||
GROUP BY w.id, w.name, ra.resource_type;
|
||||
|
||||
------ FUNCTIONS ------
|
||||
-- Calculate health score
|
||||
CREATE OR REPLACE FUNCTION calculate_health_score(website_id UUID)
|
||||
RETURNS NUMERIC AS $$
|
||||
DECLARE
|
||||
score NUMERIC;
|
||||
BEGIN
|
||||
SELECT
|
||||
AVG(
|
||||
CASE
|
||||
WHEN m.direction = 'higher_is_better' THEN
|
||||
LEAST(mv.value / NULLIF(m.default_threshold, 0) * 100, 100)
|
||||
ELSE
|
||||
LEAST(m.default_threshold / NULLIF(mv.value, 0) * 100, 100)
|
||||
END
|
||||
)
|
||||
INTO score
|
||||
FROM scans s
|
||||
JOIN metric_values mv ON mv.scan_id = s.id
|
||||
JOIN metric_definitions m ON m.id = mv.metric_id
|
||||
WHERE s.website_id = calculate_health_score.website_id
|
||||
AND s.created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND m.is_core_metric = true;
|
||||
|
||||
RETURN COALESCE(score, 0);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update scan status
|
||||
CREATE OR REPLACE FUNCTION update_scan_status()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'completed' THEN
|
||||
-- Update website's last scan timestamp
|
||||
UPDATE websites
|
||||
SET last_scan_at = NOW()
|
||||
WHERE id = NEW.website_id;
|
||||
|
||||
-- Check for alerts
|
||||
INSERT INTO alerts (website_id, page_id, severity, title, message)
|
||||
SELECT
|
||||
NEW.website_id,
|
||||
NEW.page_id,
|
||||
ac.severity,
|
||||
'Metric threshold exceeded',
|
||||
format('%s is %s threshold of %s', m.name, ac.comparison, ac.threshold)
|
||||
FROM metric_values mv
|
||||
JOIN metric_definitions m ON m.id = mv.metric_id
|
||||
JOIN alert_configurations ac ON ac.metric_id = m.id
|
||||
WHERE mv.scan_id = NEW.id
|
||||
AND ac.website_id = NEW.website_id
|
||||
AND (
|
||||
CASE ac.comparison
|
||||
WHEN 'less_than' THEN mv.value < ac.threshold
|
||||
WHEN 'greater_than' THEN mv.value > ac.threshold
|
||||
WHEN 'equal_to' THEN mv.value = ac.threshold
|
||||
END
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for scan status updates
|
||||
CREATE TRIGGER scan_status_update
|
||||
AFTER UPDATE OF status ON scans
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.status IS DISTINCT FROM NEW.status)
|
||||
EXECUTE FUNCTION update_scan_status();
|
||||
|
||||
------ INDEXES ------
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_scans_website_status ON scans(website_id, status);
|
||||
CREATE INDEX idx_scans_created_at ON scans(created_at);
|
||||
CREATE INDEX idx_metric_values_scan_metric ON metric_values(scan_id, metric_id);
|
||||
CREATE INDEX idx_pages_website_active ON pages(website_id, is_active);
|
||||
CREATE INDEX idx_crawl_queue_status_priority ON crawl_queue(status, priority);
|
||||
CREATE INDEX idx_alerts_website_status ON alerts(website_id, status);
|
||||
CREATE INDEX idx_resource_analysis_scan ON resource_analysis(scan_id);
|
||||
CREATE INDEX idx_audit_logs_organization ON audit_logs(organization_id, created_at);
|
||||
CREATE INDEX idx_metric_values_created_at ON metric_values(created_at);
|
||||
CREATE INDEX idx_pages_url_trgm ON pages USING gin (url gin_trgm_ops);
|
||||
|
||||
------ SECURITY POLICIES ------
|
||||
-- RLS Policies
|
||||
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE websites ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE metric_values ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE alerts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE dashboards ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Organization access
|
||||
CREATE POLICY "Users can view their organization"
|
||||
ON organizations
|
||||
FOR SELECT
|
||||
USING (id IN (
|
||||
SELECT organization_id FROM users WHERE id = auth.uid()
|
||||
));
|
||||
|
||||
-- Website access
|
||||
CREATE POLICY "Users can view their organization's websites"
|
||||
ON websites
|
||||
FOR SELECT
|
||||
USING (organization_id IN (
|
||||
SELECT organization_id FROM users WHERE id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Admins can manage their organization's websites"
|
||||
ON websites
|
||||
FOR ALL
|
||||
USING (
|
||||
organization_id IN (
|
||||
SELECT organization_id
|
||||
FROM users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'owner')
|
||||
)
|
||||
);
|
||||
|
||||
------ DATA RETENTION ------
|
||||
-- Create retention policy function
|
||||
CREATE OR REPLACE FUNCTION apply_data_retention()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
org RECORD;
|
||||
BEGIN
|
||||
-- Loop through organizations
|
||||
FOR org IN SELECT id, (settings->>'retention_days')::integer AS retention_days
|
||||
FROM organizations
|
||||
WHERE settings->>'retention_days' IS NOT NULL
|
||||
LOOP
|
||||
-- Delete old scan data
|
||||
DELETE FROM metric_values
|
||||
WHERE scan_id IN (
|
||||
SELECT id FROM scans
|
||||
WHERE website_id IN (
|
||||
SELECT id FROM websites WHERE organization_id = org.id
|
||||
)
|
||||
AND created_at < NOW() - (org.retention_days || ' days')::interval
|
||||
);
|
||||
|
||||
-- Delete old resource analysis
|
||||
DELETE FROM resource_analysis
|
||||
WHERE scan_id IN (
|
||||
SELECT id FROM scans
|
||||
WHERE website_id IN (
|
||||
SELECT id FROM websites WHERE organization_id = org.id
|
||||
)
|
||||
AND created_at < NOW() - (org.retention_days || ' days')::interval
|
||||
);
|
||||
|
||||
-- Delete old scans
|
||||
DELETE FROM scans
|
||||
WHERE website_id IN (
|
||||
SELECT id FROM websites WHERE organization_id = org.id
|
||||
)
|
||||
AND created_at < NOW() - (org.retention_days || ' days')::interval;
|
||||
|
||||
-- Archive resolved alerts
|
||||
UPDATE alerts
|
||||
SET status = 'archived'
|
||||
WHERE website_id IN (
|
||||
SELECT id FROM websites WHERE organization_id = org.id
|
||||
)
|
||||
AND status = 'resolved'
|
||||
AND resolved_at < NOW() - (org.retention_days || ' days')::interval;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
------ ADDITIONAL FUNCTIONS ------
|
||||
-- Calculate competitor comparison
|
||||
CREATE OR REPLACE FUNCTION calculate_competitor_comparison(website_id UUID)
|
||||
RETURNS TABLE (
|
||||
metric_key VARCHAR,
|
||||
your_score NUMERIC,
|
||||
competitor_avg NUMERIC,
|
||||
competitor_best NUMERIC,
|
||||
percentile NUMERIC
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH your_metrics AS (
|
||||
SELECT
|
||||
m.key,
|
||||
mv.value
|
||||
FROM scans s
|
||||
JOIN metric_values mv ON mv.scan_id = s.id
|
||||
JOIN metric_definitions m ON m.id = mv.metric_id
|
||||
WHERE s.website_id = calculate_competitor_comparison.website_id
|
||||
AND s.created_at = (
|
||||
SELECT MAX(created_at)
|
||||
FROM scans
|
||||
WHERE website_id = calculate_competitor_comparison.website_id
|
||||
)
|
||||
),
|
||||
competitor_metrics AS (
|
||||
SELECT
|
||||
m.key,
|
||||
mv.value,
|
||||
PERCENT_RANK() OVER (PARTITION BY m.key ORDER BY mv.value) as percentile
|
||||
FROM scans s
|
||||
JOIN metric_values mv ON mv.scan_id = s.id
|
||||
JOIN metric_definitions m ON m.id = mv.metric_id
|
||||
WHERE s.website_id IN (
|
||||
SELECT competitor_url_id
|
||||
FROM competitor_websites
|
||||
WHERE website_id = calculate_competitor_comparison.website_id
|
||||
)
|
||||
AND s.created_at >= NOW() - INTERVAL '30 days'
|
||||
)
|
||||
SELECT
|
||||
ym.key,
|
||||
ym.value as your_score,
|
||||
AVG(cm.value) as competitor_avg,
|
||||
MAX(cm.value) as competitor_best,
|
||||
MAX(cm.percentile) * 100 as percentile
|
||||
FROM your_metrics ym
|
||||
LEFT JOIN competitor_metrics cm ON cm.key = ym.key
|
||||
GROUP BY ym.key, ym.value;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Generate performance report
|
||||
CREATE OR REPLACE FUNCTION generate_performance_report(website_id UUID, days INTEGER)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
report JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'website_info', (
|
||||
SELECT jsonb_build_object(
|
||||
'name', name,
|
||||
'url', base_url,
|
||||
'report_period', jsonb_build_object(
|
||||
'start', NOW() - (days || ' days')::interval,
|
||||
'end', NOW()
|
||||
)
|
||||
)
|
||||
FROM websites
|
||||
WHERE id = website_id
|
||||
),
|
||||
'performance_summary', (
|
||||
SELECT jsonb_build_object(
|
||||
'average_performance_score', AVG(mv.value),
|
||||
'best_performance_score', MAX(mv.value),
|
||||
'worst_performance_score', MIN(mv.value),
|
||||
'trend', jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'date', DATE(s.created_at),
|
||||
'score', mv.value
|
||||
) ORDER BY s.created_at
|
||||
)
|
||||
)
|
||||
FROM scans s
|
||||
JOIN metric_values mv ON mv.scan_id = s.id
|
||||
JOIN metric_definitions md ON md.id = mv.metric_id
|
||||
WHERE s.website_id = generate_performance_report.website_id
|
||||
AND md.key = 'performance'
|
||||
AND s.created_at >= NOW() - (days || ' days')::interval
|
||||
),
|
||||
'core_metrics', (
|
||||
SELECT jsonb_object_agg(
|
||||
md.key,
|
||||
jsonb_build_object(
|
||||
'average', AVG(mv.value),
|
||||
'best', MAX(mv.value),
|
||||
'worst', MIN(mv.value)
|
||||
)
|
||||
)
|
||||
FROM scans s
|
||||
JOIN metric_values mv ON mv.scan_id = s.id
|
||||
JOIN metric_definitions md ON md.id = mv.metric_id
|
||||
WHERE s.website_id = generate_performance_report.website_id
|
||||
AND md.is_core_metric = true
|
||||
AND s.created_at >= NOW() - (days || ' days')::interval
|
||||
),
|
||||
'resource_summary', (
|
||||
SELECT jsonb_object_agg(
|
||||
resource_type,
|
||||
jsonb_build_object(
|
||||
'count', COUNT(*),
|
||||
'total_size', SUM(size_bytes),
|
||||
'average_duration', AVG(duration_ms)
|
||||
)
|
||||
)
|
||||
FROM scans s
|
||||
JOIN resource_analysis ra ON ra.scan_id = s.id
|
||||
WHERE s.website_id = generate_performance_report.website_id
|
||||
AND s.created_at >= NOW() - (days || ' days')::interval
|
||||
),
|
||||
'alerts', (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'severity', severity,
|
||||
'message', message,
|
||||
'created_at', created_at
|
||||
)
|
||||
)
|
||||
FROM alerts
|
||||
WHERE website_id = generate_performance_report.website_id
|
||||
AND created_at >= NOW() - (days || ' days')::interval
|
||||
)
|
||||
) INTO report;
|
||||
|
||||
RETURN report;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
------ NOTIFICATIONS ------
|
||||
-- Create notification function
|
||||
CREATE OR REPLACE FUNCTION process_alert_notifications()
|
||||
RETURNS trigger AS $$
|
||||
DECLARE
|
||||
website_record RECORD;
|
||||
user_record RECORD;
|
||||
BEGIN
|
||||
-- Get website details
|
||||
SELECT * INTO website_record
|
||||
FROM websites
|
||||
WHERE id = NEW.website_id;
|
||||
|
||||
-- Insert notification for each user in the organization
|
||||
FOR user_record IN
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
WHERE u.organization_id = website_record.organization_id
|
||||
AND (u.settings->>'email_notifications')::boolean = true
|
||||
LOOP
|
||||
INSERT INTO notification_deliveries (
|
||||
alert_id,
|
||||
channel,
|
||||
recipient,
|
||||
content
|
||||
) VALUES (
|
||||
NEW.id,
|
||||
'email',
|
||||
user_record.email,
|
||||
format(
|
||||
'Alert for %s: %s. Severity: %s',
|
||||
website_record.name,
|
||||
NEW.message,
|
||||
NEW.severity
|
||||
)
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for alert notifications
|
||||
CREATE TRIGGER alert_notification_trigger
|
||||
AFTER INSERT ON alerts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION process_alert_notifications();
|
||||
|
||||
------ MAINTENANCE PROCEDURES ------
|
||||
-- Create maintenance function
|
||||
CREATE OR REPLACE FUNCTION perform_maintenance()
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
-- Clean up old rate limit records
|
||||
DELETE FROM rate_limits
|
||||
WHERE window_start < NOW() - INTERVAL '1 day';
|
||||
|
||||
-- Archive old notifications
|
||||
UPDATE notification_deliveries
|
||||
SET status = 'archived'
|
||||
WHERE created_at < NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Clean up expired API keys
|
||||
UPDATE api_keys
|
||||
SET is_active = false
|
||||
WHERE expires_at < NOW();
|
||||
|
||||
-- Update statistics
|
||||
ANALYZE websites;
|
||||
ANALYZE scans;
|
||||
ANALYZE metric_values;
|
||||
ANALYZE resource_analysis;
|
||||
|
||||
-- Vacuum analyze for better query planning
|
||||
VACUUM ANALYZE websites;
|
||||
VACUUM ANALYZE scans;
|
||||
VACUUM ANALYZE metric_values;
|
||||
VACUUM ANALYZE resource_analysis;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,67 @@
|
||||
const fs = require('fs');
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
// Read environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
async function deploySchema() {
|
||||
try {
|
||||
console.log('🚀 Starting database schema deployment...');
|
||||
|
||||
// Read the SQL file
|
||||
const sqlContent = fs.readFileSync('supabase-fixes.sql', 'utf8');
|
||||
|
||||
// Split the SQL into individual statements
|
||||
const statements = sqlContent
|
||||
.split(';')
|
||||
.map(stmt => stmt.trim())
|
||||
.filter(stmt => stmt.length > 0 && !stmt.startsWith('--'));
|
||||
|
||||
console.log(`📝 Found ${statements.length} SQL statements to execute`);
|
||||
|
||||
// Execute each statement
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const statement = statements[i];
|
||||
if (statement.trim()) {
|
||||
console.log(`⚡ Executing statement ${i + 1}/${statements.length}...`);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('exec_sql', {
|
||||
sql: statement + ';'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.warn(`⚠️ Warning on statement ${i + 1}:`, error.message);
|
||||
} else {
|
||||
console.log(`✅ Statement ${i + 1} executed successfully`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Error on statement ${i + 1}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Database schema deployment completed!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error deploying schema:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
deploySchema();
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1754344171,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "03e3a284d2e16e5aaced317cf84dfb392470ca6e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750779888,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1753719760,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "0f871fffdc0e5852ec25af99ea5f09ca7be9b632",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{ pkgs, ... }: {
|
||||
packages = with pkgs; [
|
||||
# Node.js and package managers
|
||||
nodejs_20
|
||||
yarn
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
|
||||
# Database
|
||||
postgresql_15
|
||||
|
||||
# Development tools
|
||||
git
|
||||
curl
|
||||
jq
|
||||
|
||||
# Optional: Add Docker if you want to manage Docker services
|
||||
# docker
|
||||
# docker-compose
|
||||
];
|
||||
|
||||
# PostgreSQL service for local development
|
||||
services.postgres = {
|
||||
enable = true;
|
||||
package = pkgs.postgresql_15;
|
||||
initialDatabases = [{ name = "website_monitoring"; }];
|
||||
initialScript = ''
|
||||
CREATE USER website_monitoring WITH PASSWORD 'password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE website_monitoring TO website_monitoring;
|
||||
'';
|
||||
};
|
||||
|
||||
# Optional: Add Redis if needed for caching/sessions
|
||||
# services.redis.enable = true;
|
||||
|
||||
# Environment variables
|
||||
env.POSTGRES_DB = "website_monitoring";
|
||||
env.POSTGRES_USER = "website_monitoring";
|
||||
env.POSTGRES_PASSWORD = "password";
|
||||
env.DATABASE_URL = "postgresql://website_monitoring:password@localhost:5432/website_monitoring";
|
||||
|
||||
# Scripts that run when entering the environment
|
||||
enterShell = ''
|
||||
echo "🚀 Website Monitoring Frontend Development Environment"
|
||||
echo "📦 Node.js $(node --version)"
|
||||
echo "🐘 PostgreSQL $(psql --version)"
|
||||
echo ""
|
||||
echo "Available commands:"
|
||||
echo " npm run dev - Start development server"
|
||||
echo " npm run build - Build for production"
|
||||
echo " npm run lint - Run ESLint"
|
||||
echo " psql - Connect to PostgreSQL"
|
||||
echo ""
|
||||
echo "Database connection:"
|
||||
echo " Host: localhost"
|
||||
echo " Port: 5432"
|
||||
echo " Database: website_monitoring"
|
||||
echo " User: website_monitoring"
|
||||
echo " Password: password"
|
||||
echo ""
|
||||
'';
|
||||
|
||||
# Pre-commit hooks (optional)
|
||||
# pre-commit.hooks.shellcheck.enable = true;
|
||||
# pre-commit.hooks.nixpkgs-fmt.enable = true;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
||||
|
||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||
# allowUnfree: true
|
||||
|
||||
# If you're willing to use a package that's vulnerable
|
||||
# permittedInsecurePackages:
|
||||
# - "openssl-1.1.1w"
|
||||
|
||||
# If you have more than one devenv you can merge them
|
||||
#imports:
|
||||
# - ./backend
|
||||
@@ -0,0 +1,18 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
scan-worker:
|
||||
build:
|
||||
context: ./scanner-worker
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5001:5001"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,261 @@
|
||||
# Automatic Lighthouse Scanning System
|
||||
|
||||
This document describes the automatic Lighthouse scanning system that has been integrated into your website monitoring application.
|
||||
|
||||
## Overview
|
||||
|
||||
The automatic scanning system provides:
|
||||
- **Scheduled Scans**: Periodic scans based on user-configured schedules
|
||||
- **Change Detection**: Automatic scans triggered when website content changes
|
||||
- **Subscription Limits**: Respects user subscription tiers and rate limits
|
||||
- **Webhook Support**: External triggers for website changes
|
||||
- **Comprehensive UI**: User-friendly interface for managing scan schedules
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **LighthouseScanner** (`src/services/lighthouseScanner.ts`)
|
||||
- Handles core scanning logic
|
||||
- Manages change detection
|
||||
- Enforces subscription limits
|
||||
- Simulates Lighthouse scans
|
||||
|
||||
2. **ScanScheduler** (`src/services/scanScheduler.ts`)
|
||||
- Manages scheduled scans
|
||||
- Processes change detection
|
||||
- Orchestrates scan execution
|
||||
|
||||
3. **Cron Handler** (`src/app/api/cron/scan/route.ts`)
|
||||
- Main entry point for automated scans
|
||||
- Supports different scan modes
|
||||
- Provides scan statistics
|
||||
|
||||
4. **Webhook Handler** (`src/app/api/webhooks/website-change/route.ts`)
|
||||
- Receives external change notifications
|
||||
- Triggers high-priority scans
|
||||
- Validates subscription limits
|
||||
|
||||
5. **ScanScheduleManager** (`src/components/dashboard/ScanScheduleManager.tsx`)
|
||||
- User interface for managing scan schedules
|
||||
- Displays usage statistics
|
||||
- Allows manual scan triggers
|
||||
|
||||
## Features
|
||||
|
||||
### Scheduled Scanning
|
||||
- **Frequency Options**: Hourly, daily, weekly, monthly
|
||||
- **Device Types**: Desktop and/or mobile
|
||||
- **Categories**: Performance, accessibility, SEO, best practices
|
||||
- **Subscription Tiers**: Different limits per tier
|
||||
|
||||
### Change Detection
|
||||
- **Content Hashing**: Detects changes in website content
|
||||
- **Automatic Triggers**: High-priority scans when changes detected
|
||||
- **Subscription Validation**: Only available for certain tiers
|
||||
|
||||
### Subscription Management
|
||||
- **Daily Limits**: Maximum scans per day
|
||||
- **Monthly Limits**: Maximum scans per month
|
||||
- **Feature Access**: Different capabilities per tier
|
||||
- **Usage Tracking**: Real-time usage monitoring
|
||||
|
||||
### Webhook Integration
|
||||
- **External Triggers**: Receive change notifications from external systems
|
||||
- **Validation**: Verify subscription and limits
|
||||
- **Audit Logging**: Track all webhook activities
|
||||
|
||||
## Database Schema
|
||||
|
||||
The system uses several new tables:
|
||||
|
||||
### Core Tables
|
||||
- `scans`: Main scan records
|
||||
- `scan_results`: Detailed scan results
|
||||
- `pages`: Website pages with content hashes
|
||||
- `metric_values`: Individual metric values
|
||||
- `resource_analysis`: Resource usage analysis
|
||||
|
||||
### Configuration Tables
|
||||
- `metric_definitions`: Available metrics
|
||||
- `alert_configurations`: Alert settings
|
||||
- `subscription_limits`: Tier-based limits
|
||||
|
||||
### Audit Tables
|
||||
- `audit_logs`: System activity logging
|
||||
- `crawl_queue`: Crawl job queue
|
||||
- `crawl_sessions`: Crawl session tracking
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Cron Endpoints
|
||||
```
|
||||
POST /api/cron/scan?mode=all # Full scan (scheduled + change detection)
|
||||
POST /api/cron/scan?mode=scheduled # Scheduled scans only
|
||||
POST /api/cron/scan?mode=change_detection # Change detection only
|
||||
```
|
||||
|
||||
### Webhook Endpoints
|
||||
```
|
||||
POST /api/webhooks/website-change # External change notifications
|
||||
```
|
||||
|
||||
### Manual Endpoints
|
||||
```
|
||||
POST /api/cron/scan # Manual scan trigger (authenticated)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
||||
```
|
||||
|
||||
### Subscription Tiers
|
||||
- **Free**: 10 scans/day, 100 scans/month
|
||||
- **Pro**: 50 scans/day, 500 scans/month
|
||||
- **Enterprise**: 200 scans/day, 2000 scans/month
|
||||
|
||||
## Usage
|
||||
|
||||
### Setting Up Automated Scans
|
||||
|
||||
1. **Deploy the Application**
|
||||
```bash
|
||||
# Deploy to Vercel (recommended)
|
||||
vercel --prod
|
||||
|
||||
# Or deploy to your preferred platform
|
||||
```
|
||||
|
||||
2. **Set Up Cron Jobs**
|
||||
```bash
|
||||
# Run the setup script
|
||||
./scripts/setup-cron.sh
|
||||
|
||||
# Or follow the manual setup guide
|
||||
# docs/cron-setup-guide.md
|
||||
```
|
||||
|
||||
3. **Configure Database**
|
||||
```sql
|
||||
-- Run the setup script
|
||||
\i setup-database.sql
|
||||
```
|
||||
|
||||
### Managing Scan Schedules
|
||||
|
||||
1. **Access the Dashboard**
|
||||
- Navigate to `/dashboard/websites`
|
||||
- Click on a website to view details
|
||||
- Find the "Scan Schedule Management" section
|
||||
|
||||
2. **Configure Settings**
|
||||
- Toggle automatic scanning on/off
|
||||
- Set scan frequency (hourly, daily, weekly, monthly)
|
||||
- Choose device types (desktop, mobile)
|
||||
- Select scan categories
|
||||
|
||||
3. **Monitor Usage**
|
||||
- View daily and monthly scan usage
|
||||
- Check against subscription limits
|
||||
- Trigger manual scans when needed
|
||||
|
||||
### Webhook Integration
|
||||
|
||||
1. **Set Up External Monitoring**
|
||||
- Configure your external system to detect website changes
|
||||
- Send POST requests to `/api/webhooks/website-change`
|
||||
|
||||
2. **Webhook Payload**
|
||||
```json
|
||||
{
|
||||
"websiteId": "website-uuid",
|
||||
"url": "https://example.com/changed-page",
|
||||
"changeType": "content_update",
|
||||
"contentHash": "new-content-hash",
|
||||
"metadata": {
|
||||
"source": "external-system",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring and Troubleshooting
|
||||
|
||||
### Check System Status
|
||||
```bash
|
||||
# Test the cron endpoint
|
||||
curl -X POST "https://your-domain.com/api/cron/scan?mode=all"
|
||||
|
||||
# Check database logs
|
||||
SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Scans Not Running**
|
||||
- Check cron job configuration
|
||||
- Verify database connection
|
||||
- Review subscription limits
|
||||
|
||||
2. **Change Detection Not Working**
|
||||
- Ensure subscription tier supports change detection
|
||||
- Check webhook endpoint accessibility
|
||||
- Verify content hash computation
|
||||
|
||||
3. **Performance Issues**
|
||||
- Monitor scan frequency
|
||||
- Check database performance
|
||||
- Review resource usage
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Metrics
|
||||
1. Update `metric_definitions` table
|
||||
2. Modify `LighthouseScanner` class
|
||||
3. Update UI components
|
||||
|
||||
### Customizing Scan Logic
|
||||
1. Modify `performScan` method in `LighthouseScanner`
|
||||
2. Update `runLighthouse` simulation
|
||||
3. Adjust result processing
|
||||
|
||||
### Extending Subscription Tiers
|
||||
1. Update `getSubscriptionLimits` method
|
||||
2. Modify database schema
|
||||
3. Update UI components
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Authentication**: Manual endpoints require user authentication
|
||||
- **Rate Limiting**: Built-in subscription-based limits
|
||||
- **Input Validation**: All webhook inputs are validated
|
||||
- **Audit Logging**: All activities are logged for security
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- **Batch Processing**: Multiple websites processed efficiently
|
||||
- **Error Recovery**: Failed scans don't affect the system
|
||||
- **Resource Management**: Controlled resource usage
|
||||
- **Caching**: Optimized database queries
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check the troubleshooting section
|
||||
2. Review application logs
|
||||
3. Verify database setup
|
||||
4. Test endpoints manually
|
||||
5. Check subscription configuration
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Real-time Notifications**: Push notifications for scan results
|
||||
- **Advanced Analytics**: Detailed performance insights
|
||||
- **Custom Metrics**: User-defined performance metrics
|
||||
- **Integration APIs**: Third-party service integrations
|
||||
- **Machine Learning**: Predictive performance analysis
|
||||
@@ -0,0 +1,260 @@
|
||||
# Cron Job Setup Guide for Automatic Lighthouse Scanning
|
||||
|
||||
This guide will help you set up automated cron jobs to run the Lighthouse scanning system for your website monitoring application.
|
||||
|
||||
## Overview
|
||||
|
||||
The automatic scanning system includes:
|
||||
- **Scheduled Scans**: Periodic scans based on user-configured schedules
|
||||
- **Change Detection**: Automatic scans triggered when website content changes
|
||||
- **Subscription Limits**: Respects user subscription tiers and rate limits
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Environment Variables**: Ensure your `.env` file has the required Supabase configuration:
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
||||
```
|
||||
|
||||
2. **Database Setup**: Make sure all required tables are created using the `setup-database.sql` script.
|
||||
|
||||
3. **Deployed Application**: Your Next.js application should be deployed and accessible via HTTPS.
|
||||
|
||||
## Cron Job Configuration
|
||||
|
||||
### Option 1: Using Vercel Cron Jobs (Recommended)
|
||||
|
||||
If you're deploying on Vercel, you can use their built-in cron job feature:
|
||||
|
||||
1. **Create a `vercel.json` file** in your project root:
|
||||
```json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/scan?mode=all",
|
||||
"schedule": "0 */6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Schedule Explanation**:
|
||||
- `0 */6 * * *` = Every 6 hours
|
||||
- `0 */4 * * *` = Every 4 hours
|
||||
- `0 */2 * * *` = Every 2 hours
|
||||
- `0 * * * *` = Every hour
|
||||
- `*/15 * * * *` = Every 15 minutes
|
||||
|
||||
3. **Deploy to Vercel**: The cron jobs will automatically start working after deployment.
|
||||
|
||||
### Option 2: Using External Cron Services
|
||||
|
||||
#### A. Cron-job.org (Free)
|
||||
|
||||
1. Go to [cron-job.org](https://cron-job.org)
|
||||
2. Create an account and add a new cron job
|
||||
3. Set the URL to: `https://your-domain.com/api/cron/scan?mode=all`
|
||||
4. Configure the schedule (recommended: every 6 hours)
|
||||
5. Enable monitoring and notifications
|
||||
|
||||
#### B. EasyCron (Free tier available)
|
||||
|
||||
1. Go to [easycron.com](https://easycron.com)
|
||||
2. Create an account and add a new cron job
|
||||
3. Set the URL to: `https://your-domain.com/api/cron/scan?mode=all`
|
||||
4. Configure the schedule
|
||||
5. Set up email notifications for failures
|
||||
|
||||
#### C. GitHub Actions (Free for public repos)
|
||||
|
||||
1. Create `.github/workflows/cron-scan.yml`:
|
||||
```yaml
|
||||
name: Lighthouse Scan Cron Job
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Scan
|
||||
run: |
|
||||
curl -X POST "https://your-domain.com/api/cron/scan?mode=all"
|
||||
```
|
||||
|
||||
### Option 3: Server Cron Jobs (VPS/Dedicated Server)
|
||||
|
||||
If you're running on a VPS or dedicated server:
|
||||
|
||||
1. **SSH into your server**
|
||||
2. **Edit crontab**: `crontab -e`
|
||||
3. **Add the cron job**:
|
||||
```bash
|
||||
# Run every 6 hours
|
||||
0 */6 * * * curl -X POST "https://your-domain.com/api/cron/scan?mode=all"
|
||||
|
||||
# Or run every hour
|
||||
0 * * * * curl -X POST "https://your-domain.com/api/cron/scan?mode=all"
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The cron system provides several endpoints for different scan modes:
|
||||
|
||||
### 1. Full Scan (Recommended for cron jobs)
|
||||
```
|
||||
POST /api/cron/scan?mode=all
|
||||
```
|
||||
- Runs both scheduled scans and change detection
|
||||
- Respects subscription limits
|
||||
- Returns scan statistics
|
||||
|
||||
### 2. Scheduled Scans Only
|
||||
```
|
||||
POST /api/cron/scan?mode=scheduled
|
||||
```
|
||||
- Only runs scans based on user-configured schedules
|
||||
- Useful for testing or specific use cases
|
||||
|
||||
### 3. Change Detection Only
|
||||
```
|
||||
POST /api/cron/scan?mode=change_detection
|
||||
```
|
||||
- Only checks for website changes and triggers scans
|
||||
- Can be run more frequently than full scans
|
||||
|
||||
### 4. Manual Scan Trigger
|
||||
```
|
||||
POST /api/cron/scan
|
||||
```
|
||||
- Triggers a scan for a specific website
|
||||
- Requires authentication
|
||||
- Used by the ScanScheduleManager component
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### 1. Check Cron Job Status
|
||||
|
||||
You can monitor if your cron jobs are working by:
|
||||
|
||||
1. **Checking the API response**:
|
||||
```bash
|
||||
curl -X POST "https://your-domain.com/api/cron/scan?mode=all"
|
||||
```
|
||||
|
||||
2. **Expected response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Scan processing completed",
|
||||
"statistics": {
|
||||
"scheduledScansProcessed": 5,
|
||||
"changeDetectionChecks": 10,
|
||||
"scansTriggered": 3,
|
||||
"errors": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Logs
|
||||
|
||||
Check the `audit_logs` table for scan activities:
|
||||
```sql
|
||||
SELECT * FROM audit_logs
|
||||
WHERE action_type IN ('scan_scheduled', 'scan_triggered', 'change_detected')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### 3. Error Monitoring
|
||||
|
||||
Set up monitoring for:
|
||||
- HTTP 500 errors on the cron endpoint
|
||||
- Database connection failures
|
||||
- Subscription limit violations
|
||||
|
||||
## Testing Your Setup
|
||||
|
||||
### 1. Manual Test
|
||||
```bash
|
||||
# Test the cron endpoint manually
|
||||
curl -X POST "https://your-domain.com/api/cron/scan?mode=all"
|
||||
```
|
||||
|
||||
### 2. Check Database
|
||||
```sql
|
||||
-- Check if scans are being created
|
||||
SELECT * FROM scans ORDER BY created_at DESC LIMIT 5;
|
||||
|
||||
-- Check if scan results are being saved
|
||||
SELECT * FROM scan_results ORDER BY created_at DESC LIMIT 5;
|
||||
```
|
||||
|
||||
### 3. Monitor Logs
|
||||
Check your application logs for any errors or warnings related to the scanning process.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Cron job not running**:
|
||||
- Check if the URL is accessible
|
||||
- Verify HTTPS is working
|
||||
- Check server logs for errors
|
||||
|
||||
2. **No scans being triggered**:
|
||||
- Verify database tables exist
|
||||
- Check subscription tier configuration
|
||||
- Ensure websites have scan schedules configured
|
||||
|
||||
3. **Rate limiting issues**:
|
||||
- Check subscription limits in the database
|
||||
- Verify the `subscription_limits` table has correct data
|
||||
|
||||
4. **Authentication errors**:
|
||||
- Verify `SUPABASE_SERVICE_ROLE_KEY` is set correctly
|
||||
- Check if the service role has proper permissions
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging by setting:
|
||||
```env
|
||||
TASKMASTER_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
This will provide more detailed logs about the scanning process.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **API Protection**: Consider adding authentication to the cron endpoint if needed
|
||||
2. **Rate Limiting**: The system already includes subscription-based rate limiting
|
||||
3. **Error Handling**: Failed scans are logged and don't affect the overall system
|
||||
4. **Data Privacy**: Only scan websites that users have explicitly added
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
1. **Scan Frequency**: Start with every 6 hours, adjust based on usage
|
||||
2. **Batch Processing**: The system processes multiple websites in batches
|
||||
3. **Error Recovery**: Failed scans are retried automatically
|
||||
4. **Resource Usage**: Monitor server resources during scan execution
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Set up the cron job** using one of the methods above
|
||||
2. **Test the system** with a few websites
|
||||
3. **Monitor performance** and adjust scan frequency as needed
|
||||
4. **Set up alerts** for cron job failures
|
||||
5. **Configure webhooks** for external change detection triggers
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
1. Check the troubleshooting section above
|
||||
2. Review application logs
|
||||
3. Verify database setup
|
||||
4. Test the API endpoint manually
|
||||
5. Check subscription configuration
|
||||
@@ -0,0 +1,22 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,15 @@
|
||||
import nextJest from 'next/jest.js';
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
});
|
||||
|
||||
const customJestConfig = {
|
||||
testEnvironment: 'jsdom',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
};
|
||||
|
||||
export default createJestConfig(customJestConfig);
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
eslint: {
|
||||
// Do not fail production builds due to ESLint errors
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+14910
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "website-monitoring",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"dev:all": "concurrently \"npm run dev\" \"cd ../backend && npm run dev\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.2.2",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.50.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"chrome-launcher": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"framer-motion": "^12.4.10",
|
||||
"jsdom": "^26.0.0",
|
||||
"lighthouse": "^12.6.1",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"puppeteer": "^24.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-circular-progressbar": "^2.2.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"recharts": "^2.15.1",
|
||||
"supabase": "^2.15.8",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"tailwindcss": "^4.0.9",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
+7304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320">
|
||||
<path fill="#0099ff" fill-opacity="1" d="M0,160L48,170.7C96,181,192,203,288,197.3C384,192,480,160,576,138.7C672,117,768,107,864,128C960,149,1056,203,1152,213.3C1248,224,1344,192,1392,176L1440,160L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,21 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Install Chromium for Lighthouse
|
||||
RUN apt-get update && \
|
||||
apt-get install -y wget ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 libgdk-pixbuf2.0-0 libnspr4 libnss3 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils chromium && \
|
||||
ln -s /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install -g typescript
|
||||
|
||||
RUN tsc
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
CMD ["node", "dist/express-worker.js"]
|
||||
@@ -0,0 +1,171 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { launch } from "chrome-launcher";
|
||||
import lighthouse from "lighthouse";
|
||||
import cors from "cors";
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/health", (req: Request, res: Response) => {
|
||||
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Enhanced lighthouse endpoint
|
||||
app.post("/lighthouse", async (req: Request, res: Response) => {
|
||||
const { url } = req.body;
|
||||
if (!url) return res.status(400).json({ error: "Missing URL" });
|
||||
|
||||
console.log(`Starting Lighthouse scan for: ${url}`);
|
||||
|
||||
try {
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (urlError) {
|
||||
return res.status(400).json({ error: "Invalid URL format" });
|
||||
}
|
||||
|
||||
const chrome = await launch({
|
||||
chromeFlags: [
|
||||
"--headless",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-features=TranslateUI",
|
||||
"--disable-ipc-flooding-protection",
|
||||
],
|
||||
});
|
||||
|
||||
console.log(`Chrome launched on port: ${chrome.port}`);
|
||||
|
||||
const runnerResult = await lighthouse(url, {
|
||||
port: chrome.port,
|
||||
output: "json",
|
||||
logLevel: "info",
|
||||
onlyCategories: ["performance", "seo", "accessibility", "best-practices"],
|
||||
formFactor: "desktop",
|
||||
screenEmulation: {
|
||||
mobile: false,
|
||||
width: 1350,
|
||||
height: 940,
|
||||
deviceScaleFactor: 1,
|
||||
disabled: false,
|
||||
},
|
||||
throttling: {
|
||||
rttMs: 40,
|
||||
throughputKbps: 10240,
|
||||
cpuSlowdownMultiplier: 1,
|
||||
requestLatencyMs: 0,
|
||||
downloadThroughputKbps: 0,
|
||||
uploadThroughputKbps: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await chrome.kill();
|
||||
|
||||
if (!runnerResult) throw new Error("Lighthouse returned no result");
|
||||
|
||||
const { categories, audits } = runnerResult.lhr;
|
||||
|
||||
// Extract key metrics for easier access
|
||||
const metrics = {
|
||||
performance: categories.performance?.score
|
||||
? Math.round(categories.performance.score * 100)
|
||||
: null,
|
||||
seo: categories.seo?.score
|
||||
? Math.round(categories.seo.score * 100)
|
||||
: null,
|
||||
accessibility: categories.accessibility?.score
|
||||
? Math.round(categories.accessibility.score * 100)
|
||||
: null,
|
||||
bestPractices: categories["best-practices"]?.score
|
||||
? Math.round(categories["best-practices"].score * 100)
|
||||
: null,
|
||||
firstContentfulPaint:
|
||||
audits["first-contentful-paint"]?.numericValue || null,
|
||||
largestContentfulPaint:
|
||||
audits["largest-contentful-paint"]?.numericValue || null,
|
||||
cumulativeLayoutShift:
|
||||
audits["cumulative-layout-shift"]?.numericValue || null,
|
||||
totalBlockingTime: audits["total-blocking-time"]?.numericValue || null,
|
||||
speedIndex: audits["speed-index"]?.numericValue || null,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Lighthouse scan completed for: ${url} - Performance: ${metrics.performance}%`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
categories,
|
||||
audits,
|
||||
metrics,
|
||||
raw: runnerResult.lhr,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Lighthouse scan failed for ${url}:`, error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
url: url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Batch lighthouse endpoint for multiple URLs
|
||||
app.post("/lighthouse/batch", async (req: Request, res: Response) => {
|
||||
const { urls } = req.body;
|
||||
if (!urls || !Array.isArray(urls) || urls.length === 0) {
|
||||
return res.status(400).json({ error: "Missing or empty URLs array" });
|
||||
}
|
||||
|
||||
if (urls.length > 10) {
|
||||
return res.status(400).json({ error: "Maximum 10 URLs allowed per batch" });
|
||||
}
|
||||
|
||||
console.log(`Starting batch Lighthouse scan for ${urls.length} URLs`);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
// Make internal request to single lighthouse endpoint
|
||||
const response = await fetch(`http://localhost:${PORT}/lighthouse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
results.push({ url, success: response.ok, data: result });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
url,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
results,
|
||||
total: urls.length,
|
||||
successful: results.filter((r) => r.success).length,
|
||||
failed: results.filter((r) => !r.success).length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Lighthouse worker listening on http://localhost:${PORT}`);
|
||||
console.log(`Health check available at http://localhost:${PORT}/health`);
|
||||
});
|
||||
+3062
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "scanner-worker",
|
||||
"version": "1.0.0",
|
||||
"main": "express-worker.ts",
|
||||
"scripts": {
|
||||
"start": "ts-node express-worker.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.50.0",
|
||||
"chrome-launcher": "^0.15.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.18.2",
|
||||
"lighthouse": "^12.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { launch } from "chrome-launcher";
|
||||
import lighthouse from "lighthouse";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_KEY!,
|
||||
);
|
||||
|
||||
async function main() {
|
||||
// 1. Hole alle offenen Scans
|
||||
const { data: scans, error: scanError } = await supabase
|
||||
.from("scans")
|
||||
.select("id, page_id")
|
||||
.eq("status", "pending");
|
||||
|
||||
if (scanError) {
|
||||
console.error("Fehler beim Laden der Scans:", scanError);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const scan of scans ?? []) {
|
||||
try {
|
||||
// 2. Hole die URL der Seite
|
||||
const { data: page, error: pageError } = await supabase
|
||||
.from("pages")
|
||||
.select("url")
|
||||
.eq("id", scan.page_id)
|
||||
.single();
|
||||
|
||||
if (pageError || !page) {
|
||||
console.error("Fehler beim Laden der Seite:", pageError);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Setze Scan-Status auf "running"
|
||||
await supabase
|
||||
.from("scans")
|
||||
.update({ status: "running" })
|
||||
.eq("id", scan.id);
|
||||
|
||||
// 4. Starte Lighthouse
|
||||
const chrome = await launch({
|
||||
chromeFlags: ["--headless", "--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
const runnerResult = await lighthouse(page.url, {
|
||||
port: chrome.port,
|
||||
output: "json",
|
||||
logLevel: "info",
|
||||
onlyCategories: [
|
||||
"performance",
|
||||
"seo",
|
||||
"accessibility",
|
||||
"best-practices",
|
||||
],
|
||||
});
|
||||
|
||||
await chrome.kill();
|
||||
|
||||
if (!runnerResult) throw new Error("Lighthouse returned no result");
|
||||
|
||||
// 5. Speichere Ergebnisse
|
||||
await supabase.from("scan_results").insert([
|
||||
{
|
||||
scan_id: scan.id,
|
||||
raw_data: runnerResult.lhr,
|
||||
},
|
||||
]);
|
||||
|
||||
// 6. Setze Scan-Status auf "completed"
|
||||
await supabase
|
||||
.from("scans")
|
||||
.update({ status: "completed" })
|
||||
.eq("id", scan.id);
|
||||
|
||||
console.log(`Scan für ${page.url} abgeschlossen.`);
|
||||
} catch (error) {
|
||||
console.error("Scan-Fehler:", error);
|
||||
await supabase
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
.eq("id", scan.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Lighthouse Scanner Cron Job Setup Script
|
||||
# This script helps you set up automated scanning for your website monitoring application
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Lighthouse Scanner Cron Job Setup"
|
||||
echo "====================================="
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "❌ Error: Please run this script from your project root directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if vercel.json exists
|
||||
if [ -f "vercel.json" ]; then
|
||||
echo "✅ Found vercel.json - Vercel cron jobs are configured"
|
||||
echo " Schedule: Every 6 hours"
|
||||
echo " Endpoint: /api/cron/scan?mode=all"
|
||||
echo ""
|
||||
echo "📝 To deploy with Vercel cron jobs:"
|
||||
echo " 1. Deploy to Vercel: vercel --prod"
|
||||
echo " 2. Cron jobs will start automatically"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check if GitHub Actions workflow exists
|
||||
if [ -f ".github/workflows/cron-scan.yml" ]; then
|
||||
echo "✅ Found GitHub Actions workflow"
|
||||
echo " Schedule: Every 6 hours"
|
||||
echo " File: .github/workflows/cron-scan.yml"
|
||||
echo ""
|
||||
echo "📝 To use GitHub Actions:"
|
||||
echo " 1. Push this repository to GitHub"
|
||||
echo " 2. Set DEPLOYMENT_URL secret in repository settings"
|
||||
echo " 3. Workflow will run automatically"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check environment variables
|
||||
echo "🔧 Environment Check:"
|
||||
if [ -f ".env" ]; then
|
||||
echo "✅ Found .env file"
|
||||
|
||||
# Check for required Supabase variables
|
||||
if grep -q "NEXT_PUBLIC_SUPABASE_URL" .env; then
|
||||
echo "✅ NEXT_PUBLIC_SUPABASE_URL is configured"
|
||||
else
|
||||
echo "❌ NEXT_PUBLIC_SUPABASE_URL is missing"
|
||||
fi
|
||||
|
||||
if grep -q "NEXT_PUBLIC_SUPABASE_ANON_KEY" .env; then
|
||||
echo "✅ NEXT_PUBLIC_SUPABASE_ANON_KEY is configured"
|
||||
else
|
||||
echo "❌ NEXT_PUBLIC_SUPABASE_ANON_KEY is missing"
|
||||
fi
|
||||
|
||||
if grep -q "SUPABASE_SERVICE_ROLE_KEY" .env; then
|
||||
echo "✅ SUPABASE_SERVICE_ROLE_KEY is configured"
|
||||
else
|
||||
echo "❌ SUPABASE_SERVICE_ROLE_KEY is missing"
|
||||
fi
|
||||
else
|
||||
echo "❌ No .env file found"
|
||||
echo " Please create .env file with your Supabase configuration"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📚 Documentation:"
|
||||
echo " - Cron setup guide: docs/cron-setup-guide.md"
|
||||
echo " - Database setup: setup-database.sql"
|
||||
echo ""
|
||||
|
||||
echo "🎯 Next Steps:"
|
||||
echo " 1. Ensure your database is set up with all required tables"
|
||||
echo " 2. Configure your environment variables"
|
||||
echo " 3. Deploy your application"
|
||||
echo " 4. Set up cron jobs using one of the methods above"
|
||||
echo " 5. Test the system by visiting your dashboard"
|
||||
echo ""
|
||||
|
||||
echo "🔍 Testing:"
|
||||
echo " You can test the cron endpoint manually:"
|
||||
echo " curl -X POST 'https://your-domain.com/api/cron/scan?mode=all'"
|
||||
echo ""
|
||||
|
||||
echo "✅ Setup complete! Check the documentation for detailed instructions."
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup database for website monitoring frontend
|
||||
echo "Setting up database..."
|
||||
|
||||
# Start PostgreSQL service if not running
|
||||
if ! pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
|
||||
echo "Starting PostgreSQL service..."
|
||||
devenv up &
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Create database and user if they don't exist
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE website_monitoring;" 2>/dev/null || echo "Database already exists"
|
||||
psql -h localhost -U postgres -c "CREATE USER website_monitoring WITH PASSWORD 'password';" 2>/dev/null || echo "User already exists"
|
||||
psql -h localhost -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE website_monitoring TO website_monitoring;" 2>/dev/null
|
||||
|
||||
# Run the schema setup
|
||||
echo "Running database schema setup..."
|
||||
psql -h localhost -U website_monitoring -d website_monitoring -f setup-database.sql
|
||||
|
||||
# Add missing column if it doesn't exist
|
||||
echo "Adding missing scheduled_at column..."
|
||||
psql -h localhost -U website_monitoring -d website_monitoring -c "ALTER TABLE scans ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ;"
|
||||
|
||||
echo "Database setup complete!"
|
||||
@@ -0,0 +1,547 @@
|
||||
-- Website Monitoring Frontend - Database Setup Script
|
||||
-- Run this in your Supabase SQL editor to create all required tables
|
||||
|
||||
-- Enable necessary extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
------ ENUMS ------
|
||||
-- Core enums for status and types
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scan_status AS ENUM (
|
||||
'pending',
|
||||
'queued',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE severity_level AS ENUM (
|
||||
'critical',
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
'info'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE comparison_operator AS ENUM (
|
||||
'less_than',
|
||||
'less_than_equal',
|
||||
'greater_than',
|
||||
'greater_than_equal',
|
||||
'equal_to',
|
||||
'not_equal_to'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE metric_category AS ENUM (
|
||||
'performance',
|
||||
'seo',
|
||||
'accessibility',
|
||||
'best_practices',
|
||||
'security',
|
||||
'pwa'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE resource_type AS ENUM (
|
||||
'script',
|
||||
'stylesheet',
|
||||
'image',
|
||||
'font',
|
||||
'document',
|
||||
'media',
|
||||
'other'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE notification_channel AS ENUM (
|
||||
'email',
|
||||
'slack',
|
||||
'webhook',
|
||||
'in_app'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE subscription_tier AS ENUM (
|
||||
'free',
|
||||
'starter',
|
||||
'professional',
|
||||
'enterprise'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE user_role AS ENUM (
|
||||
'owner',
|
||||
'admin',
|
||||
'editor',
|
||||
'viewer'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
------ CORE TABLES ------
|
||||
-- Organizations table (if not exists)
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR NOT NULL,
|
||||
subscription_tier subscription_tier DEFAULT 'free',
|
||||
subscription_status VARCHAR DEFAULT 'active',
|
||||
billing_email VARCHAR,
|
||||
max_websites INTEGER DEFAULT 5,
|
||||
max_users INTEGER DEFAULT 3,
|
||||
scan_frequency_minutes INTEGER DEFAULT 60,
|
||||
settings JSONB DEFAULT '{
|
||||
"alert_email_digest": "daily",
|
||||
"default_scan_depth": 3,
|
||||
"retention_days": 90,
|
||||
"enable_competitor_analysis": false
|
||||
}'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Users table (if not exists)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email VARCHAR UNIQUE NOT NULL,
|
||||
name VARCHAR,
|
||||
organization_id UUID REFERENCES organizations(id),
|
||||
role user_role DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
settings JSONB DEFAULT '{
|
||||
"email_notifications": true,
|
||||
"notification_frequency": "instant",
|
||||
"dashboard_layout": "default"
|
||||
}'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Websites table (if not exists)
|
||||
CREATE TABLE IF NOT EXISTS websites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) NOT NULL,
|
||||
base_url VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
crawl_settings JSONB DEFAULT '{
|
||||
"max_pages": 100,
|
||||
"max_depth": 3,
|
||||
"exclude_patterns": [
|
||||
"/admin/*",
|
||||
"/api/*",
|
||||
"*.pdf",
|
||||
"*.jpg",
|
||||
"*.png"
|
||||
],
|
||||
"include_patterns": ["/*"],
|
||||
"respect_robots_txt": true,
|
||||
"crawl_frequency": "daily",
|
||||
"crawl_timing": "off_peak"
|
||||
}'::jsonb,
|
||||
scan_schedule JSONB DEFAULT '{
|
||||
"frequency": "hourly",
|
||||
"time_windows": ["0-6", "20-23"],
|
||||
"days": ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
||||
}'::jsonb,
|
||||
performance_budgets JSONB DEFAULT '{
|
||||
"page_weight_kb": 1000,
|
||||
"max_requests": 100,
|
||||
"time_to_interactive_ms": 3000,
|
||||
"first_contentful_paint_ms": 1000
|
||||
}'::jsonb,
|
||||
notifications JSONB DEFAULT '{
|
||||
"channels": ["email"],
|
||||
"thresholds": {
|
||||
"performance": 90,
|
||||
"accessibility": 90,
|
||||
"seo": 90,
|
||||
"best_practices": 90
|
||||
}
|
||||
}'::jsonb,
|
||||
last_crawl_at TIMESTAMPTZ,
|
||||
last_scan_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(organization_id, base_url)
|
||||
);
|
||||
|
||||
-- Pages table (MISSING - this is causing the 400 errors)
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
url VARCHAR NOT NULL,
|
||||
path VARCHAR NOT NULL,
|
||||
title VARCHAR,
|
||||
description TEXT,
|
||||
content_hash VARCHAR,
|
||||
template_hash VARCHAR,
|
||||
content_type VARCHAR,
|
||||
status_code INTEGER,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
priority INTEGER DEFAULT 1,
|
||||
depth INTEGER DEFAULT 0,
|
||||
parent_page_id UUID REFERENCES pages(id),
|
||||
discovery_method VARCHAR DEFAULT 'crawl',
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{
|
||||
"inbound_links": 0,
|
||||
"outbound_links": 0,
|
||||
"word_count": 0,
|
||||
"has_canonical": false,
|
||||
"is_indexable": true
|
||||
}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(website_id, url)
|
||||
);
|
||||
|
||||
------ METRIC DEFINITIONS ------
|
||||
CREATE TABLE IF NOT EXISTS metric_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
key VARCHAR NOT NULL UNIQUE,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category metric_category NOT NULL,
|
||||
unit VARCHAR,
|
||||
is_core_metric BOOLEAN DEFAULT false,
|
||||
default_threshold NUMERIC,
|
||||
warning_threshold NUMERIC,
|
||||
critical_threshold NUMERIC,
|
||||
direction VARCHAR NOT NULL DEFAULT 'higher_is_better',
|
||||
weight NUMERIC DEFAULT 1.0,
|
||||
documentation_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Populate core metrics (if not already populated)
|
||||
INSERT INTO metric_definitions
|
||||
(key, name, description, category, unit, is_core_metric, default_threshold, warning_threshold, critical_threshold, direction)
|
||||
VALUES
|
||||
-- Core Web Vitals
|
||||
('performance', 'Performance Score', 'Overall performance score of the website', 'performance', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
('accessibility', 'Accessibility Score', 'Overall accessibility score of the website', 'accessibility', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
('seo', 'SEO Score', 'Overall SEO score of the website', 'seo', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
('bestPractices', 'Best Practices Score', 'Overall best practices score', 'best_practices', '%', true, 90, 80, 70, 'higher_is_better'),
|
||||
|
||||
-- Performance Metrics
|
||||
('firstContentfulPaint', 'First Contentful Paint', 'Time when the first text or image is painted', 'performance', 'ms', true, 1800, 2500, 4000, 'lower_is_better'),
|
||||
('largestContentfulPaint', 'Largest Contentful Paint', 'Time when the largest text or image is painted', 'performance', 'ms', true, 2500, 4000, 6000, 'lower_is_better'),
|
||||
('totalBlockingTime', 'Total Blocking Time', 'Sum of all time periods between FCP and Time to Interactive', 'performance', 'ms', true, 200, 400, 600, 'lower_is_better'),
|
||||
('cumulativeLayoutShift', 'Cumulative Layout Shift', 'Measures visual stability', 'performance', 'score', true, 0.1, 0.25, 0.4, 'lower_is_better'),
|
||||
('speedIndex', 'Speed Index', 'How quickly content is visually displayed', 'performance', 'ms', true, 3400, 5800, 8800, 'lower_is_better'),
|
||||
('interactive', 'Time to Interactive', 'Time to fully interactive', 'performance', 'ms', true, 3800, 7300, 12700, 'lower_is_better'),
|
||||
|
||||
-- Resource Metrics
|
||||
('totalByteWeight', 'Total Byte Weight', 'Total size of all resources', 'performance', 'bytes', false, 1600000, 2400000, 3200000, 'lower_is_better'),
|
||||
('serverResponseTime', 'Server Response Time', 'Time for server to respond to main document request', 'performance', 'ms', false, 100, 200, 400, 'lower_is_better'),
|
||||
('networkRtt', 'Network Round Trip Time', 'Network round trip time', 'performance', 'ms', false, 40, 100, 150, 'lower_is_better'),
|
||||
('networkServerLatency', 'Network Server Latency', 'Server latency in network requests', 'performance', 'ms', false, 30, 100, 150, 'lower_is_better')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
------ SCANS AND RESULTS (MISSING - this is causing the 400 errors) ------
|
||||
-- Scans table
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
page_id UUID REFERENCES pages(id) NOT NULL,
|
||||
triggered_by UUID REFERENCES users(id),
|
||||
scan_type VARCHAR NOT NULL DEFAULT 'full',
|
||||
status scan_status DEFAULT 'pending',
|
||||
priority INTEGER DEFAULT 1,
|
||||
categories metric_category[] DEFAULT ARRAY['performance', 'seo', 'accessibility', 'best_practices'],
|
||||
device_type VARCHAR DEFAULT 'desktop',
|
||||
user_agent VARCHAR,
|
||||
lighthouse_version VARCHAR,
|
||||
chrome_version VARCHAR,
|
||||
environment JSONB DEFAULT '{}'::jsonb,
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Scan results table (MISSING - this is causing the 400 errors)
|
||||
CREATE TABLE IF NOT EXISTS scan_results (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
category metric_category NOT NULL,
|
||||
score NUMERIC,
|
||||
raw_data JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Metric values table
|
||||
CREATE TABLE IF NOT EXISTS metric_values (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
metric_id UUID REFERENCES metric_definitions(id) NOT NULL,
|
||||
value NUMERIC NOT NULL,
|
||||
raw_value VARCHAR,
|
||||
unit VARCHAR,
|
||||
is_passing BOOLEAN,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Resource analysis table
|
||||
CREATE TABLE IF NOT EXISTS resource_analysis (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scan_id UUID REFERENCES scans(id) NOT NULL,
|
||||
resource_type resource_type NOT NULL,
|
||||
url VARCHAR NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
transfer_size_bytes INTEGER,
|
||||
duration_ms INTEGER,
|
||||
is_third_party BOOLEAN DEFAULT false,
|
||||
is_cached BOOLEAN,
|
||||
compression_ratio NUMERIC,
|
||||
mime_type VARCHAR,
|
||||
protocol VARCHAR,
|
||||
priority VARCHAR,
|
||||
status_code INTEGER,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ ALERTS (MISSING - this is causing the 400 errors) ------
|
||||
-- Alert configurations
|
||||
CREATE TABLE IF NOT EXISTS alert_configurations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
metric_id UUID REFERENCES metric_definitions(id) NOT NULL,
|
||||
threshold NUMERIC NOT NULL,
|
||||
comparison comparison_operator DEFAULT 'less_than',
|
||||
severity severity_level DEFAULT 'medium',
|
||||
consecutive_count INTEGER DEFAULT 1,
|
||||
cooldown_minutes INTEGER DEFAULT 60,
|
||||
notification_channels notification_channel[] DEFAULT ARRAY['email'],
|
||||
notification_template TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_triggered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alerts table
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
page_id UUID REFERENCES pages(id),
|
||||
config_id UUID REFERENCES alert_configurations(id),
|
||||
metric_id UUID REFERENCES metric_definitions(id),
|
||||
severity severity_level DEFAULT 'medium',
|
||||
title VARCHAR NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
details JSONB DEFAULT '{}'::jsonb,
|
||||
status VARCHAR DEFAULT 'open',
|
||||
acknowledged_by UUID REFERENCES users(id),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ CRAWL MANAGEMENT ------
|
||||
-- Crawl queue
|
||||
CREATE TABLE IF NOT EXISTS crawl_queue (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
url VARCHAR NOT NULL,
|
||||
priority INTEGER DEFAULT 1,
|
||||
status VARCHAR DEFAULT 'pending',
|
||||
parent_url VARCHAR,
|
||||
discovery_depth INTEGER DEFAULT 0,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
next_attempt_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Crawl sessions
|
||||
CREATE TABLE IF NOT EXISTS crawl_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
website_id UUID REFERENCES websites(id) NOT NULL,
|
||||
status VARCHAR DEFAULT 'running',
|
||||
pages_discovered INTEGER DEFAULT 0,
|
||||
pages_processed INTEGER DEFAULT 0,
|
||||
start_url VARCHAR NOT NULL,
|
||||
max_depth INTEGER,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
------ INDEXES ------
|
||||
-- Performance indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_scans_website_status ON scans(website_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_scans_created_at ON scans(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_scan_results_scan_id ON scan_results(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_metric_values_scan_metric ON metric_values(scan_id, metric_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_website_active ON pages(website_id, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_queue_status_priority ON crawl_queue(status, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_website_status ON alerts(website_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_analysis_scan ON resource_analysis(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_metric_values_created_at ON metric_values(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_url_trgm ON pages USING gin (url gin_trgm_ops);
|
||||
|
||||
------ SAMPLE DATA FOR TESTING ------
|
||||
-- Insert a sample organization if none exists
|
||||
INSERT INTO organizations (id, name, subscription_tier, subscription_status)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'Demo Organization',
|
||||
'free',
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Insert a sample website if none exists
|
||||
INSERT INTO websites (id, organization_id, base_url, name, is_active)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'https://example.com',
|
||||
'Example Website',
|
||||
true
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Insert a sample page if none exists
|
||||
INSERT INTO pages (id, website_id, url, path, title, is_active)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
'https://example.com',
|
||||
'/',
|
||||
'Example Homepage',
|
||||
true
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Insert a sample scan if none exists
|
||||
INSERT INTO scans (id, website_id, page_id, status, scan_type, device_type)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000004',
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000003',
|
||||
'completed',
|
||||
'full',
|
||||
'desktop'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Insert sample scan results
|
||||
INSERT INTO scan_results (scan_id, category, score, raw_data)
|
||||
VALUES
|
||||
('00000000-0000-0000-0000-000000000004', 'performance', 85, '{"firstContentfulPaint": 1200, "largestContentfulPaint": 2100}'),
|
||||
('00000000-0000-0000-0000-000000000004', 'seo', 92, '{"metaDescription": true, "titleTag": true}'),
|
||||
('00000000-0000-0000-0000-000000000004', 'accessibility', 88, '{"ariaLabels": 5, "contrastRatio": "4.5:1"}'),
|
||||
('00000000-0000-0000-0000-000000000004', 'best_practices', 95, '{"usesHttps": true, "noConsoleErrors": true}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert sample metric values
|
||||
INSERT INTO metric_values (scan_id, metric_id, value, unit, is_passing)
|
||||
SELECT
|
||||
'00000000-0000-0000-0000-000000000004',
|
||||
md.id,
|
||||
CASE md.key
|
||||
WHEN 'performance' THEN 85
|
||||
WHEN 'seo' THEN 92
|
||||
WHEN 'accessibility' THEN 88
|
||||
WHEN 'bestPractices' THEN 95
|
||||
WHEN 'firstContentfulPaint' THEN 1200
|
||||
WHEN 'largestContentfulPaint' THEN 2100
|
||||
WHEN 'totalBlockingTime' THEN 150
|
||||
WHEN 'cumulativeLayoutShift' THEN 0.05
|
||||
ELSE 80
|
||||
END,
|
||||
md.unit,
|
||||
CASE md.key
|
||||
WHEN 'performance' THEN true
|
||||
WHEN 'seo' THEN true
|
||||
WHEN 'accessibility' THEN true
|
||||
WHEN 'bestPractices' THEN true
|
||||
WHEN 'firstContentfulPaint' THEN true
|
||||
WHEN 'largestContentfulPaint' THEN true
|
||||
WHEN 'totalBlockingTime' THEN true
|
||||
WHEN 'cumulativeLayoutShift' THEN true
|
||||
ELSE true
|
||||
END
|
||||
FROM metric_definitions md
|
||||
WHERE md.key IN ('performance', 'seo', 'accessibility', 'bestPractices', 'firstContentfulPaint', 'largestContentfulPaint', 'totalBlockingTime', 'cumulativeLayoutShift')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
------ ROW LEVEL SECURITY ------
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE websites ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scan_results ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE metric_values ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE alerts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE alert_configurations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE crawl_queue ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE crawl_sessions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Basic RLS policies (you may need to adjust these based on your auth setup)
|
||||
CREATE POLICY "Enable read access for authenticated users" ON organizations FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON users FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON websites FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON pages FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON scans FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON scan_results FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON metric_values FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON alerts FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON alert_configurations FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON crawl_queue FOR SELECT USING (true);
|
||||
CREATE POLICY "Enable read access for authenticated users" ON crawl_sessions FOR SELECT USING (true);
|
||||
|
||||
-- Success message
|
||||
SELECT 'Database setup completed successfully! All required tables have been created.' as status;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/organizations
|
||||
*
|
||||
* List all organizations with usage stats.
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: orgs, count, error } = await supabase
|
||||
.from("organizations")
|
||||
.select("*", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Enrich with counts
|
||||
const enrichedOrgs = await Promise.all(
|
||||
(orgs || []).map(async (org) => {
|
||||
const orgId = org.id as string;
|
||||
const [
|
||||
{ count: memberCount },
|
||||
{ count: websiteCount },
|
||||
{ count: scanCount },
|
||||
] = await Promise.all([
|
||||
supabase.from("organization_members").select("*", { count: "exact", head: true }).eq("organization_id", orgId),
|
||||
supabase.from("websites").select("*", { count: "exact", head: true }).eq("organization_id", orgId),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...org,
|
||||
memberCount: memberCount || 0,
|
||||
websiteCount: websiteCount || 0,
|
||||
scanCount: scanCount || 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: enrichedOrgs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
totalPages: Math.ceil((count || 0) / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/organizations
|
||||
*
|
||||
* Update organization: change tier, deactivate, etc.
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { organizationId, updates } = await request.json();
|
||||
|
||||
if (!organizationId || !updates) {
|
||||
return NextResponse.json(
|
||||
{ error: "organizationId and updates required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update(updates)
|
||||
.eq("id", organizationId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true, message: "Organization updated" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
*
|
||||
* Returns system-wide statistics for the admin dashboard.
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Parallel queries for stats
|
||||
const [
|
||||
{ count: totalUsers },
|
||||
{ count: totalOrgs },
|
||||
{ count: totalWebsites },
|
||||
{ count: totalScans },
|
||||
{ count: scansToday },
|
||||
{ count: scansThisMonth },
|
||||
{ count: activeAlerts },
|
||||
{ count: uptimeChecks24h },
|
||||
] = await Promise.all([
|
||||
supabase.from("users").select("*", { count: "exact", head: true }),
|
||||
supabase.from("organizations").select("*", { count: "exact", head: true }),
|
||||
supabase.from("websites").select("*", { count: "exact", head: true }),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }).gte("created_at", last24h),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }).gte("created_at", last30d),
|
||||
supabase.from("alerts").select("*", { count: "exact", head: true }).eq("status", "active"),
|
||||
supabase.from("uptime_checks").select("*", { count: "exact", head: true }).gte("checked_at", last24h),
|
||||
]);
|
||||
|
||||
// Get uptime summary
|
||||
const { data: uptimeDown } = await supabase
|
||||
.from("uptime_checks")
|
||||
.select("id")
|
||||
.eq("status", "down")
|
||||
.gte("checked_at", last24h);
|
||||
|
||||
const uptimeUpPercentage =
|
||||
uptimeChecks24h && uptimeChecks24h > 0
|
||||
? ((uptimeChecks24h - (uptimeDown?.length || 0)) / uptimeChecks24h) * 100
|
||||
: 100;
|
||||
|
||||
// Recent scans with status breakdown
|
||||
const { data: scanStatusBreakdown } = await supabase
|
||||
.from("scans")
|
||||
.select("status")
|
||||
.gte("created_at", last24h);
|
||||
|
||||
const scansByStatus = (scanStatusBreakdown || []).reduce(
|
||||
(acc: Record<string, number>, s) => {
|
||||
const key = String(s.status || "unknown");
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
users: totalUsers || 0,
|
||||
organizations: totalOrgs || 0,
|
||||
websites: totalWebsites || 0,
|
||||
scans: {
|
||||
total: totalScans || 0,
|
||||
today: scansToday || 0,
|
||||
thisMonth: scansThisMonth || 0,
|
||||
byStatus: scansByStatus,
|
||||
},
|
||||
alerts: {
|
||||
active: activeAlerts || 0,
|
||||
},
|
||||
uptime: {
|
||||
checksLast24h: uptimeChecks24h || 0,
|
||||
overallUptime: Math.round(uptimeUpPercentage * 100) / 100,
|
||||
},
|
||||
timestamp: now.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
*
|
||||
* List all users with their organization memberships and usage stats.
|
||||
* Query params: ?page=1&limit=20&search=keyword
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20");
|
||||
const search = url.searchParams.get("search") || "";
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get users from the users table
|
||||
let query = supabase
|
||||
.from("users")
|
||||
.select("id, email, name, created_at, last_sign_in_at, organization_id", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (search) {
|
||||
query = query.or(`email.ilike.%${search}%,name.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data: users, count, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Enrich with organization info
|
||||
const enrichedUsers = await Promise.all(
|
||||
(users || []).map(async (user) => {
|
||||
const userId = user.id as string;
|
||||
// Get org memberships
|
||||
const { data: memberships } = await supabase
|
||||
.from("organization_members")
|
||||
.select("role, organizations(name, subscription_tier)")
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Get scan count
|
||||
const { count: scanCount } = await supabase
|
||||
.from("scans")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("triggered_by", "manual");
|
||||
|
||||
return {
|
||||
...user,
|
||||
memberships: memberships || [],
|
||||
totalScans: scanCount || 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
users: enrichedUsers,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
totalPages: Math.ceil((count || 0) / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users
|
||||
*
|
||||
* Update user: change role, deactivate, change subscription tier.
|
||||
* Body: { userId, action, value }
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { userId, action, value } = await request.json();
|
||||
|
||||
if (!userId || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: "userId and action are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "changeRole": {
|
||||
const { organizationId, newRole } = value;
|
||||
if (!organizationId || !newRole) {
|
||||
return NextResponse.json({ error: "organizationId and newRole required" }, { status: 400 });
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from("organization_members")
|
||||
.update({ role: newRole })
|
||||
.eq("user_id", userId)
|
||||
.eq("organization_id", organizationId);
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: `Role updated to ${newRole}` });
|
||||
}
|
||||
|
||||
case "changeTier": {
|
||||
const { organizationId, tier } = value;
|
||||
if (!organizationId || !tier) {
|
||||
return NextResponse.json({ error: "organizationId and tier required" }, { status: 400 });
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ subscription_tier: tier })
|
||||
.eq("id", organizationId);
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: `Subscription tier changed to ${tier}` });
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const { error } = await supabase.auth.admin.updateUserById(userId, {
|
||||
ban_duration: "876000h", // ~100 years
|
||||
});
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: "User deactivated" });
|
||||
}
|
||||
|
||||
case "activate": {
|
||||
const { error } = await supabase.auth.admin.updateUserById(userId, {
|
||||
ban_duration: "none",
|
||||
});
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: "User activated" });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users
|
||||
*
|
||||
* Delete a user and their data.
|
||||
* Body: { userId }
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { userId } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove from all organizations first
|
||||
await supabase
|
||||
.from("organization_members")
|
||||
.delete()
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Delete notification preferences
|
||||
await supabase
|
||||
.from("user_notification_preferences")
|
||||
.delete()
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Delete the auth user
|
||||
const { error } = await supabase.auth.admin.deleteUser(userId);
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true, message: "User deleted" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { pageId, websiteId, triggerType = "manual" } = await request.json();
|
||||
|
||||
if (!pageId && !websiteId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Either pageId or websiteId must be provided" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let pagesToScan = [];
|
||||
|
||||
if (pageId) {
|
||||
// Scan specific page
|
||||
const { data: page, error: pageError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, website_id")
|
||||
.eq("id", pageId)
|
||||
.single();
|
||||
|
||||
if (pageError || !page) {
|
||||
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
pagesToScan = [page];
|
||||
} else {
|
||||
// Scan all active pages for website
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, website_id")
|
||||
.eq("website_id", websiteId)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (pagesError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch pages" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
pagesToScan = pages || [];
|
||||
}
|
||||
|
||||
if (pagesToScan.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "No active pages found to scan" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create scans for all pages
|
||||
const scanPromises = pagesToScan.map(async (page) => {
|
||||
const { data: scan, error: scanError } = await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
page_id: page.id,
|
||||
status: "pending",
|
||||
trigger_type: triggerType,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (scanError) {
|
||||
console.error(`Failed to create scan for page ${page.id}:`, scanError);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trigger Lighthouse scan
|
||||
try {
|
||||
await triggerLighthouseScan(scan.id as string, page.url as string);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to trigger Lighthouse scan for ${page.url}:`,
|
||||
error,
|
||||
);
|
||||
// Mark scan as failed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scan.id as string);
|
||||
}
|
||||
|
||||
return scan;
|
||||
});
|
||||
|
||||
const scans = await Promise.all(scanPromises);
|
||||
const successfulScans = scans.filter((scan) => scan !== null);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Started ${successfulScans.length} scans`,
|
||||
scanIds: successfulScans.map((scan) => scan.id),
|
||||
totalPages: pagesToScan.length,
|
||||
successfulScans: successfulScans.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Analysis error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Failed to start analysis: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerLighthouseScan(scanId: string, url: string) {
|
||||
// Update scan status to running
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
|
||||
try {
|
||||
// Call Lighthouse service
|
||||
const lighthouseUrl =
|
||||
process.env.LIGHTHOUSE_SERVICE_URL || "http://localhost:5001";
|
||||
const response = await fetch(`${lighthouseUrl}/lighthouse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Lighthouse service responded with ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Save scan results
|
||||
await saveScanResults(scanId, result);
|
||||
|
||||
// Update scan status to completed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
} catch (error) {
|
||||
console.error(`Lighthouse scan failed for ${url}:`, error);
|
||||
|
||||
// Update scan status to failed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScanResults(scanId: string, lighthouseResult: any) {
|
||||
try {
|
||||
const { categories, audits, raw } = lighthouseResult;
|
||||
|
||||
// Save raw Lighthouse data
|
||||
await getSupabaseAdmin().from("scan_results").insert([
|
||||
{
|
||||
scan_id: scanId,
|
||||
raw_data: raw,
|
||||
performance_score: categories?.performance?.score
|
||||
? Math.round(categories.performance.score * 100)
|
||||
: null,
|
||||
seo_score: categories?.seo?.score
|
||||
? Math.round(categories.seo.score * 100)
|
||||
: null,
|
||||
accessibility_score: categories?.accessibility?.score
|
||||
? Math.round(categories.accessibility.score * 100)
|
||||
: null,
|
||||
best_practices_score: categories?.["best-practices"]?.score
|
||||
? Math.round(categories["best-practices"].score * 100)
|
||||
: null,
|
||||
first_contentful_paint:
|
||||
audits?.["first-contentful-paint"]?.numericValue || null,
|
||||
largest_contentful_paint:
|
||||
audits?.["largest-contentful-paint"]?.numericValue || null,
|
||||
cumulative_layout_shift:
|
||||
audits?.["cumulative-layout-shift"]?.numericValue || null,
|
||||
total_blocking_time:
|
||||
audits?.["total-blocking-time"]?.numericValue || null,
|
||||
speed_index: audits?.["speed-index"]?.numericValue || null,
|
||||
},
|
||||
]);
|
||||
|
||||
// Extract and save metric values
|
||||
const metricValues = [];
|
||||
|
||||
// Core Web Vitals
|
||||
if (audits?.["first-contentful-paint"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "first_contentful_paint",
|
||||
value: audits["first-contentful-paint"].numericValue,
|
||||
score: audits["first-contentful-paint"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["largest-contentful-paint"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "largest_contentful_paint",
|
||||
value: audits["largest-contentful-paint"].numericValue,
|
||||
score: audits["largest-contentful-paint"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["cumulative-layout-shift"]?.numericValue !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "cumulative_layout_shift",
|
||||
value: audits["cumulative-layout-shift"].numericValue,
|
||||
score: audits["cumulative-layout-shift"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["total-blocking-time"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "total_blocking_time",
|
||||
value: audits["total-blocking-time"].numericValue,
|
||||
score: audits["total-blocking-time"].score,
|
||||
});
|
||||
}
|
||||
|
||||
// Category scores
|
||||
if (categories?.performance?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "performance_score",
|
||||
value: Math.round(categories.performance.score * 100),
|
||||
score: categories.performance.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.seo?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "seo_score",
|
||||
value: Math.round(categories.seo.score * 100),
|
||||
score: categories.seo.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.accessibility?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "accessibility_score",
|
||||
value: Math.round(categories.accessibility.score * 100),
|
||||
score: categories.accessibility.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.["best-practices"]?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "best_practices_score",
|
||||
value: Math.round(categories["best-practices"].score * 100),
|
||||
score: categories["best-practices"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (metricValues.length > 0) {
|
||||
await getSupabaseAdmin().from("metric_values").insert(metricValues);
|
||||
}
|
||||
|
||||
// Save resource analysis if available
|
||||
if (raw?.audits?.["resource-summary"]?.details?.items) {
|
||||
const resources = raw.audits["resource-summary"].details.items.map(
|
||||
(item: any) => ({
|
||||
scan_id: scanId,
|
||||
resource_type: item.resourceType || "other",
|
||||
count: item.requestCount || 0,
|
||||
size_bytes: item.size || 0,
|
||||
transfer_size_bytes: item.transferSize || 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await getSupabaseAdmin().from("resource_analysis").insert(resources);
|
||||
}
|
||||
|
||||
console.log(`Successfully saved scan results for scan ${scanId}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save scan results for scan ${scanId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { TIER_LIMITS } from "@/services/tierLimits";
|
||||
import { requireOrgMembership } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/billing/usage
|
||||
*
|
||||
* Returns current usage vs tier limits for an organization.
|
||||
* Requires authenticated user who is a member of the organization.
|
||||
* Query params: ?organizationId=xxx
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: "organizationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify caller belongs to this organization
|
||||
const auth = await requireOrgMembership(organizationId, request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
// Get organization with tier info
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("id, name, subscription_tier, subscription_status, created_at")
|
||||
.eq("id", organizationId)
|
||||
.single();
|
||||
|
||||
if (orgError || !org) {
|
||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const tier = String(org.subscription_tier || "free");
|
||||
const limits = TIER_LIMITS[tier] || TIER_LIMITS.free;
|
||||
|
||||
// Get current usage (parallel queries)
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
{ count: websiteCount },
|
||||
{ count: memberCount },
|
||||
{ count: scanCountThisMonth },
|
||||
{ count: scanCountTotal },
|
||||
{ count: alertCount },
|
||||
] = await Promise.all([
|
||||
supabase.from("websites").select("*", { count: "exact", head: true }).eq("organization_id", organizationId),
|
||||
supabase.from("organization_members").select("*", { count: "exact", head: true }).eq("organization_id", organizationId),
|
||||
supabase
|
||||
.from("scans")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("created_at", startOfMonth.toISOString()),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }),
|
||||
supabase.from("alerts").select("*", { count: "exact", head: true }).eq("status", "active"),
|
||||
]);
|
||||
|
||||
const usage = {
|
||||
websites: { used: websiteCount || 0, limit: limits.websites, percentage: limits.websites === -1 ? 0 : Math.round(((websiteCount || 0) / limits.websites) * 100) },
|
||||
scansThisMonth: { used: scanCountThisMonth || 0, limit: limits.scansPerMonth, percentage: limits.scansPerMonth === -1 ? 0 : Math.round(((scanCountThisMonth || 0) / limits.scansPerMonth) * 100) },
|
||||
teamMembers: { used: memberCount || 0, limit: limits.teamMembers, percentage: limits.teamMembers === -1 ? 0 : Math.round(((memberCount || 0) / limits.teamMembers) * 100) },
|
||||
totalScans: scanCountTotal || 0,
|
||||
activeAlerts: alertCount || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
organization: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
tier,
|
||||
status: org.subscription_status || "active",
|
||||
createdAt: org.created_at,
|
||||
},
|
||||
plan: limits,
|
||||
usage,
|
||||
features: {
|
||||
scheduledScans: limits.scheduledScans,
|
||||
alertNotifications: limits.alertNotifications,
|
||||
competitorAnalysis: limits.competitorAnalysis,
|
||||
apiAccess: limits.apiAccess,
|
||||
prioritySupport: limits.prioritySupport,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
/**
|
||||
* GET /api/competitor-analysis?websiteId=xxx
|
||||
*
|
||||
* Returns your website's latest scores alongside competitor scores.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const websiteId = url.searchParams.get("websiteId");
|
||||
|
||||
if (!websiteId) {
|
||||
return NextResponse.json({ error: "websiteId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get your website's latest scan results
|
||||
const { data: website } = await supabase
|
||||
.from("websites")
|
||||
.select("id, name, base_url")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (!website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get latest scan results for your site
|
||||
const { data: yourScans } = await supabase
|
||||
.from("scan_results")
|
||||
.select("category, score, scans(website_id, created_at)")
|
||||
.eq("scans.website_id", websiteId)
|
||||
.order("created_at", { ascending: false, referencedTable: "scans" })
|
||||
.limit(4);
|
||||
|
||||
const yourScores: Record<string, number> = {};
|
||||
for (const scan of yourScans || []) {
|
||||
const category = String(scan.category || "");
|
||||
const score = Number(scan.score);
|
||||
if (category && !isNaN(score) && !yourScores[category]) {
|
||||
yourScores[category] = score;
|
||||
}
|
||||
}
|
||||
|
||||
// Get competitor entries for this website
|
||||
const { data: competitors } = await supabase
|
||||
.from("competitor_metrics")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
return NextResponse.json({
|
||||
yourSite: {
|
||||
id: website.id,
|
||||
name: website.name,
|
||||
url: website.base_url,
|
||||
scores: {
|
||||
performance: yourScores.performance ?? null,
|
||||
seo: yourScores.seo ?? null,
|
||||
accessibility: yourScores.accessibility ?? null,
|
||||
bestPractices: yourScores.best_practices ?? yourScores.bestPractices ?? null,
|
||||
},
|
||||
},
|
||||
competitors: (competitors || []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name || c.url,
|
||||
url: c.url,
|
||||
scores: {
|
||||
performance: c.performance_score,
|
||||
seo: c.seo_score,
|
||||
accessibility: c.accessibility_score,
|
||||
bestPractices: c.best_practices_score,
|
||||
},
|
||||
lastScanned: c.last_scanned_at,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Competitor analysis error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch competitor data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/competitor-analysis
|
||||
*
|
||||
* Add a competitor and scan it with Lighthouse.
|
||||
* Body: { websiteId, competitorUrl, competitorName }
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { websiteId, competitorUrl, competitorName } = await request.json();
|
||||
|
||||
if (!websiteId || !competitorUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "websiteId and competitorUrl required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(competitorUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Run a lightweight fetch-based check (no full Lighthouse to save resources)
|
||||
const start = Date.now();
|
||||
let statusCode = null;
|
||||
let responseTime = 0;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const res = await fetch(competitorUrl, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "WebsiteMonitor/1.0 (Competitor Analysis)" },
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
statusCode = res.status;
|
||||
responseTime = Date.now() - start;
|
||||
} catch {
|
||||
responseTime = Date.now() - start;
|
||||
}
|
||||
|
||||
// Insert competitor record
|
||||
const { data: competitor, error } = await supabase
|
||||
.from("competitor_metrics")
|
||||
.upsert(
|
||||
{
|
||||
website_id: websiteId,
|
||||
url: competitorUrl,
|
||||
name: competitorName || new URL(competitorUrl).hostname,
|
||||
status_code: statusCode,
|
||||
response_time: responseTime,
|
||||
last_scanned_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "website_id,url" }
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
competitor,
|
||||
message: `Competitor added: ${competitorUrl} (${responseTime}ms)`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Competitor add error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to add competitor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ pages: [] });
|
||||
}
|
||||
|
||||
// Hole alle gefundenen Seiten für die Session
|
||||
const { data: pages } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("url")
|
||||
.eq("session_id", sessionId);
|
||||
|
||||
return NextResponse.json({ pages: pages?.map((p) => p.url) || [] });
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { NewCrawlerService } from "@/services/newCrawlerService";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse request body
|
||||
let websiteId;
|
||||
try {
|
||||
const body = await request.json();
|
||||
websiteId = body.websiteId;
|
||||
|
||||
// Normalize the websiteId to ensure consistent format
|
||||
websiteId = String(websiteId).trim().toLowerCase();
|
||||
console.log("Processing website ID:", websiteId);
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing request body:", parseError);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!websiteId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch website details using admin client (bypasses RLS)
|
||||
const { data: websites, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, name, base_url")
|
||||
.eq("id", websiteId);
|
||||
|
||||
if (websiteError) {
|
||||
console.error("Website query error:", websiteError);
|
||||
return NextResponse.json(
|
||||
{ error: `Database error: ${websiteError.message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if website exists
|
||||
if (!websites || websites.length === 0) {
|
||||
console.error("Website not found with ID:", websiteId);
|
||||
|
||||
// Try to find similar websites for debugging
|
||||
const { data: allWebsites } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, name, base_url");
|
||||
|
||||
console.log(`Found ${allWebsites?.length || 0} total websites`);
|
||||
|
||||
// Log available IDs for comparison
|
||||
const availableIds =
|
||||
allWebsites?.map((w) => String(w.id).toLowerCase()) || [];
|
||||
console.log("Available website IDs:", availableIds);
|
||||
|
||||
return NextResponse.json({
|
||||
error: "Website not found",
|
||||
debug: {
|
||||
requestedId: websiteId,
|
||||
availableIds: availableIds,
|
||||
totalWebsites: allWebsites?.length || 0
|
||||
}
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Website found, proceed with crawl
|
||||
const website = websites[0];
|
||||
console.log("Found website:", website.name, website.base_url);
|
||||
|
||||
// Create a new crawl session (using admin client)
|
||||
const { data: sessions, error: sessionError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.insert([
|
||||
{
|
||||
website_id: website.id, // Use the ID from the database
|
||||
status: "pending",
|
||||
start_url: website.base_url,
|
||||
total_urls: 0,
|
||||
processed_urls: 0,
|
||||
progress_percentage: 0,
|
||||
pages_discovered: 0,
|
||||
pages_processed: 0,
|
||||
},
|
||||
])
|
||||
.select();
|
||||
|
||||
if (sessionError) {
|
||||
console.error("Failed to create crawl session:", sessionError);
|
||||
console.error("Session error details:", {
|
||||
message: sessionError.message,
|
||||
details: sessionError.details,
|
||||
hint: sessionError.hint,
|
||||
code: sessionError.code
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to create crawl session",
|
||||
details: sessionError.message,
|
||||
code: sessionError.code
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Session was created but not returned" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = sessions[0] as { id: string };
|
||||
console.log("Created crawl session:", session.id);
|
||||
|
||||
// Start crawler in background
|
||||
const crawler = new NewCrawlerService(String(website.id), String(session.id));
|
||||
crawler.startCrawl().catch((err) => {
|
||||
console.error("Crawler error:", err);
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Crawl started",
|
||||
sessionId: session.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Catch-all error handler
|
||||
console.error("Crawl initialization error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start crawl: " + (error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching crawl status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch crawl status" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { scanScheduler } from "@/services/scanScheduler";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
import { verifyCronSecret } from "@/lib/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = url.searchParams.get("mode") || "all"; // "scheduled", "change_detection", "all"
|
||||
const organizationId = url.searchParams.get("organizationId"); // Optional: limit to specific org
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scan_process_start', mode, timestamp: new Date().toISOString() }));
|
||||
|
||||
const results = {
|
||||
scheduledScans: 0,
|
||||
changeDetectionScans: 0,
|
||||
errors: [] as string[],
|
||||
startTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Process scheduled scans
|
||||
if (mode === "scheduled" || mode === "all") {
|
||||
try {
|
||||
console.info(JSON.stringify({ level: 'info', event: 'processing_scheduled_scans', timestamp: new Date().toISOString() }));
|
||||
await scanScheduler.processScheduledScans();
|
||||
|
||||
// Get count of processed scans
|
||||
const scheduledScans = await scanScheduler.getScheduledScans();
|
||||
results.scheduledScans = scheduledScans.length;
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scheduled_scans_processed', count: results.scheduledScans, timestamp: new Date().toISOString() }));
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing scheduled scans: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Process change detection
|
||||
if (mode === "change_detection" || mode === "all") {
|
||||
try {
|
||||
console.info(JSON.stringify({ level: 'info', event: 'processing_change_detection', timestamp: new Date().toISOString() }));
|
||||
await scanScheduler.processChangeDetection();
|
||||
|
||||
// Note: Change detection count is harder to track since it's based on actual changes
|
||||
// We'll just indicate it was processed
|
||||
results.changeDetectionScans = -1; // -1 indicates processed but count unknown
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'change_detection_processed', timestamp: new Date().toISOString() }));
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing change detection: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Get overall statistics
|
||||
const stats = await getScanStatistics(organizationId ?? undefined);
|
||||
|
||||
const response = {
|
||||
success: results.errors.length === 0,
|
||||
message: `Automatic scan process completed - ${results.scheduledScans} scheduled scans, change detection processed`,
|
||||
results,
|
||||
statistics: stats,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scan_process_completed', success: response.success, timestamp: new Date().toISOString() }));
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const errorMsg = `Critical error in automatic scan process: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan statistics for monitoring
|
||||
*/
|
||||
async function getScanStatistics(organizationId?: string) {
|
||||
try {
|
||||
const { getSupabaseAdmin } = await import("@/lib/admin");
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('scans')
|
||||
.select('id, status, created_at, triggered_by');
|
||||
|
||||
if (organizationId) {
|
||||
const { data: websitesForOrg, error: orgErr } = await supabase
|
||||
.from('websites')
|
||||
.select('id')
|
||||
.eq('organization_id', organizationId);
|
||||
if (orgErr) {
|
||||
throw orgErr;
|
||||
}
|
||||
const websiteIds = (websitesForOrg || []).map((w: any) => w.id);
|
||||
if (websiteIds.length === 0) {
|
||||
return {
|
||||
today: { total: 0, byStatus: {}, byTrigger: {} },
|
||||
thisMonth: { total: 0 },
|
||||
last24Hours: { total: 0 },
|
||||
};
|
||||
}
|
||||
query = query.in('website_id', websiteIds as any[]);
|
||||
}
|
||||
|
||||
// Get today's scans
|
||||
const { data: todayScans } = await query
|
||||
.gte('created_at', startOfDay.toISOString());
|
||||
|
||||
// Get this month's scans
|
||||
const { data: monthScans } = await query
|
||||
.gte('created_at', startOfMonth.toISOString());
|
||||
|
||||
// Get scans by status
|
||||
const { data: statusCounts } = await query
|
||||
.select('status') as unknown as { data: Array<{ status: string }> };
|
||||
|
||||
const statusBreakdown = (statusCounts?.reduce((acc: Record<string, number>, scan: { status: string }) => {
|
||||
const key = String(scan.status || 'unknown');
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)) || {};
|
||||
|
||||
// Get scans by trigger type
|
||||
const { data: triggerCounts } = await query
|
||||
.select('triggered_by') as unknown as { data: Array<{ triggered_by: string | null }> };
|
||||
|
||||
const triggerBreakdown = (triggerCounts?.reduce((acc: Record<string, number>, scan: { triggered_by: string | null }) => {
|
||||
const trigger = String(scan.triggered_by || 'unknown');
|
||||
acc[trigger] = (acc[trigger] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)) || {};
|
||||
|
||||
return {
|
||||
today: {
|
||||
total: todayScans?.length || 0,
|
||||
byStatus: statusBreakdown,
|
||||
byTrigger: triggerBreakdown,
|
||||
},
|
||||
thisMonth: {
|
||||
total: monthScans?.length || 0,
|
||||
},
|
||||
last24Hours: {
|
||||
total: todayScans?.length || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Error getting scan statistics', error);
|
||||
return {
|
||||
today: { total: 0, byStatus: {}, byTrigger: {} },
|
||||
thisMonth: { total: 0 },
|
||||
last24Hours: { total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual scan trigger endpoint
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { websiteId, pageId, deviceType = 'desktop', categories, priority = 'medium' } = body;
|
||||
|
||||
if (!websiteId || !pageId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID and Page ID are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'manual_scan_triggered', websiteId, pageId, timestamp: new Date().toISOString() }));
|
||||
|
||||
// Check subscription limits
|
||||
const { data: website } = await (await import("@/lib/admin")).getSupabaseAdmin()
|
||||
.from('websites')
|
||||
.select('organization_id')
|
||||
.eq('id', websiteId)
|
||||
.single();
|
||||
|
||||
if (!website) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { canScan, limits, currentUsage } = await lighthouseScanner.checkSubscriptionLimits(
|
||||
String(website.organization_id)
|
||||
);
|
||||
|
||||
if (!canScan) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Subscription limit exceeded",
|
||||
limits,
|
||||
currentUsage,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
const scanConfig = {
|
||||
websiteId,
|
||||
pageId,
|
||||
deviceType: deviceType as 'desktop' | 'mobile',
|
||||
categories: categories || ['performance', 'accessibility', 'seo', 'best_practices'],
|
||||
priority: priority as 'low' | 'medium' | 'high',
|
||||
triggeredBy: 'manual' as const,
|
||||
};
|
||||
|
||||
const result = await lighthouseScanner.performScan(scanConfig);
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scanId: result.scanId,
|
||||
message: "Scan completed successfully",
|
||||
metrics: result.metrics,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error in manual scan: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { performUptimeChecks, evaluateUptimeAlerts } from "@/services/uptimeService";
|
||||
import { verifyCronSecret } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/cron/uptime
|
||||
*
|
||||
* Performs uptime checks on all active websites and evaluates alert rules.
|
||||
* Designed to be called by a cron job (e.g., GitHub Actions, Vercel Cron, or external scheduler).
|
||||
* Requires CRON_SECRET authorization in production.
|
||||
*
|
||||
* Query params:
|
||||
* - alerts=true (default) — also evaluate alert rules after checks
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const shouldEvaluateAlerts = url.searchParams.get("alerts") !== "false";
|
||||
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
level: "info",
|
||||
event: "uptime_check_start",
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// Perform uptime checks
|
||||
const checkResults = await performUptimeChecks();
|
||||
|
||||
// Evaluate alert rules
|
||||
let alertsTriggered = 0;
|
||||
if (shouldEvaluateAlerts) {
|
||||
alertsTriggered = await evaluateUptimeAlerts();
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
message: `Uptime checks completed: ${checkResults.checked} websites checked`,
|
||||
results: {
|
||||
...checkResults,
|
||||
alertsTriggered,
|
||||
},
|
||||
duration: `${duration}ms`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
level: "info",
|
||||
event: "uptime_check_complete",
|
||||
...response.results,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const errorMsg = `Uptime check failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
console.error(JSON.stringify({ level: "error", event: "uptime_check_error", error: errorMsg }));
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMsg, timestamp: new Date().toISOString() },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { websiteId } = await params;
|
||||
|
||||
// Get crawl queue items
|
||||
const { data: queueItems, error: queueError } = await getSupabaseAdmin()
|
||||
.from("crawl_queue")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (queueError) throw queueError;
|
||||
|
||||
// Get crawl sessions
|
||||
const { data: sessions, error: sessionsError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (sessionsError) throw sessionsError;
|
||||
|
||||
// Get pages discovered
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, title, is_active, depth, created_at, metadata")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (pagesError) throw pagesError;
|
||||
|
||||
// Get website info
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("*")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError) throw websiteError;
|
||||
|
||||
// Statistics
|
||||
const queueStats = {
|
||||
total: queueItems?.length || 0,
|
||||
pending: queueItems?.filter(item => item.status === 'pending').length || 0,
|
||||
processing: queueItems?.filter(item => item.status === 'processing').length || 0,
|
||||
completed: queueItems?.filter(item => item.status === 'completed').length || 0,
|
||||
failed: queueItems?.filter(item => item.status === 'failed').length || 0,
|
||||
skipped: queueItems?.filter(item => item.status === 'skipped').length || 0,
|
||||
};
|
||||
|
||||
const sessionStats = {
|
||||
total: sessions?.length || 0,
|
||||
running: sessions?.filter(s => s.status === 'running').length || 0,
|
||||
completed: sessions?.filter(s => s.status === 'completed').length || 0,
|
||||
failed: sessions?.filter(s => s.status === 'failed').length || 0,
|
||||
};
|
||||
|
||||
const pageStats = {
|
||||
total: pages?.length || 0,
|
||||
active: pages?.filter(p => p.is_active).length || 0,
|
||||
inactive: pages?.filter(p => !p.is_active).length || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
website,
|
||||
queueStats,
|
||||
sessionStats,
|
||||
pageStats,
|
||||
queueItems: queueItems?.slice(0, 20), // Last 20 queue items
|
||||
sessions: sessions?.slice(0, 5), // Last 5 sessions
|
||||
pages: pages?.slice(0, 20), // Last 20 pages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching crawl debug info:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch crawl debug info" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { websiteId } = await context.params;
|
||||
|
||||
// Get crawl sessions for this website
|
||||
const { data: sessions, error: sessionsError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
// Get all pages for this website
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return NextResponse.json({
|
||||
sessions,
|
||||
sessionsError,
|
||||
pages,
|
||||
pagesError,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
// In-memory rate limiter (for demo/dev only; use Redis for production)
|
||||
const rateLimit: Record<string, { count: number; last: number }> = {};
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role for backend
|
||||
);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = req.headers.get('x-forwarded-for') || '';
|
||||
const now = Date.now();
|
||||
|
||||
// Rate limiting
|
||||
if (!rateLimit[ip]) rateLimit[ip] = { count: 0, last: now };
|
||||
if (now - rateLimit[ip].last > WINDOW_MS) {
|
||||
rateLimit[ip] = { count: 0, last: now };
|
||||
}
|
||||
rateLimit[ip].count += 1;
|
||||
if (rateLimit[ip].count > MAX_ATTEMPTS) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let email: string | undefined;
|
||||
try {
|
||||
const body = await req.json();
|
||||
email = body.email;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Debug log incoming email
|
||||
console.log('API /api/email-exists: email =', email);
|
||||
|
||||
const { data, error } = await supabase.rpc('email_exists', { email_to_check: email });
|
||||
|
||||
// Debug log Supabase response
|
||||
console.log('API /api/email-exists: supabase.rpc response =', { data, error });
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: 'Server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ exists: data === true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { websiteId } = await request.json();
|
||||
|
||||
// Create a new scan
|
||||
const { data: scan, error: scanError } = await supabase
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
status: "pending",
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (scanError) throw scanError;
|
||||
|
||||
// Trigger the analysis process
|
||||
const response = await fetch("/api/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
websiteId,
|
||||
scanId: scan.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to start analysis");
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, scanId: scan.id });
|
||||
} catch (error) {
|
||||
console.error("Monitor start error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start monitoring" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NotificationService } from "@/services/notificationService";
|
||||
|
||||
/**
|
||||
* POST /api/notifications/process
|
||||
*
|
||||
* Processes all pending alert notifications.
|
||||
* Sends emails via Resend and webhooks to configured URLs.
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await NotificationService.processNotifications();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Notifications] Processing failed:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!organizationId || !userId) {
|
||||
return NextResponse.json({ error: "Organization ID and User ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get all members of the organization
|
||||
const { data: members, error: membersError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("id, name, email, role, created_at")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (membersError) {
|
||||
console.error("Error fetching members:", membersError);
|
||||
return NextResponse.json({ error: "Failed to fetch members" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ members });
|
||||
} catch (error) {
|
||||
console.error("Error in members GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { organizationId, email, role, invitedBy } = await request.json();
|
||||
|
||||
if (!organizationId || !email || !role || !invitedBy) {
|
||||
return NextResponse.json({
|
||||
error: "Organization ID, email, role, and inviter ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify inviter has permission (must be owner or admin)
|
||||
const { data: inviter, error: inviterError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", invitedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (inviterError || !inviter) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (inviter.role !== "owner" && inviter.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can invite members"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if user already exists in the system
|
||||
const { data: existingUsers, error: userCheckError } = await getSupabaseAdmin()
|
||||
.auth.admin.listUsers();
|
||||
|
||||
if (userCheckError) {
|
||||
console.error("Error checking existing users:", userCheckError);
|
||||
return NextResponse.json({ error: "Failed to check existing users" }, { status: 500 });
|
||||
}
|
||||
|
||||
const existingUser = existingUsers.users.find(u => u.email === email);
|
||||
|
||||
if (existingUser) {
|
||||
// Check if user is already in an organization
|
||||
const { data: userRecord } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.eq("id", existingUser.id)
|
||||
.single();
|
||||
|
||||
if (userRecord?.organization_id) {
|
||||
if (userRecord.organization_id === organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "User is already a member of this organization"
|
||||
}, { status: 400 });
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
error: "User is already a member of another organization"
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Add existing user to organization
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ organization_id: organizationId, role })
|
||||
.eq("id", existingUser.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error adding existing user to organization:", updateError);
|
||||
return NextResponse.json({ error: "Failed to add user to organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get updated user data
|
||||
const { data: updatedUser } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("id, name, email, role, created_at")
|
||||
.eq("id", existingUser.id)
|
||||
.single();
|
||||
|
||||
return NextResponse.json({
|
||||
member: updatedUser,
|
||||
message: "Existing user added to organization"
|
||||
});
|
||||
} else {
|
||||
// Create invitation record for new user
|
||||
// Note: In a real app, you'd send an email invitation here
|
||||
// For now, we'll just create a placeholder record
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Invitation would be sent to new user",
|
||||
action: "invitation_sent"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in members POST:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { memberId, role, updatedBy, organizationId } = await request.json();
|
||||
|
||||
if (!memberId || !role || !updatedBy || !organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "Member ID, role, updater ID, and organization ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify updater has permission (must be owner)
|
||||
const { data: updater, error: updaterError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", updatedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (updaterError || !updater) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (updater.role !== "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners can update member roles"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow changing the role of the organization owner
|
||||
const { data: targetMember } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("role")
|
||||
.eq("id", memberId)
|
||||
.single();
|
||||
|
||||
if (targetMember?.role === "owner" && role !== "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Cannot change the role of the organization owner"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Update member role
|
||||
const { data: updatedMember, error: updateError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ role })
|
||||
.eq("id", memberId)
|
||||
.eq("organization_id", organizationId)
|
||||
.select("id, name, email, role, created_at")
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating member role:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update member role" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ member: updatedMember });
|
||||
} catch (error) {
|
||||
console.error("Error in members PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const memberId = url.searchParams.get("memberId");
|
||||
const removedBy = url.searchParams.get("removedBy");
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
|
||||
if (!memberId || !removedBy || !organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "Member ID, remover ID, and organization ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify remover has permission (must be owner or admin)
|
||||
const { data: remover, error: removerError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", removedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (removerError || !remover) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (remover.role !== "owner" && remover.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can remove members"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow removing the organization owner
|
||||
const { data: targetMember } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("role")
|
||||
.eq("id", memberId)
|
||||
.single();
|
||||
|
||||
if (targetMember?.role === "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Cannot remove the organization owner"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove member from organization (set organization_id to null)
|
||||
const { error: removeError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ organization_id: null, role: "member" })
|
||||
.eq("id", memberId)
|
||||
.eq("organization_id", organizationId);
|
||||
|
||||
if (removeError) {
|
||||
console.error("Error removing member:", removeError);
|
||||
return NextResponse.json({ error: "Failed to remove member" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in members DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { userId, name } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.insert([
|
||||
{
|
||||
name: name || "My Organization",
|
||||
subscription_tier: "free",
|
||||
subscription_status: "active",
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orgError) {
|
||||
throw orgError;
|
||||
}
|
||||
|
||||
// Update user with organization ID
|
||||
const { error: userError } = await supabase
|
||||
.from("users")
|
||||
.update({ organization_id: org.id })
|
||||
.eq("id", userId);
|
||||
|
||||
if (userError) {
|
||||
throw userError;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
organization: org,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Organization creation error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create organization" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get organizations where user is a member
|
||||
const { data: userOrgs, error: userOrgError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select(`
|
||||
organization_id,
|
||||
role,
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
subscription_tier,
|
||||
subscription_status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq("id", userId);
|
||||
|
||||
if (userOrgError) {
|
||||
console.error("Error fetching user organizations:", userOrgError);
|
||||
return NextResponse.json({ error: "Failed to fetch organizations" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get organization stats
|
||||
const orgIds = userOrgs?.map(u => u.organization_id).filter(Boolean) || [];
|
||||
|
||||
if (orgIds.length === 0) {
|
||||
return NextResponse.json({ organizations: [] });
|
||||
}
|
||||
|
||||
const [membersData, websitesData] = await Promise.all([
|
||||
// Get member counts
|
||||
getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds),
|
||||
|
||||
// Get website counts
|
||||
getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds)
|
||||
]);
|
||||
|
||||
const memberCounts = (membersData.data as Array<{ organization_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, member) => {
|
||||
const key = String(member.organization_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const websiteCounts = (websitesData.data as Array<{ organization_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, website) => {
|
||||
const key = String(website.organization_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const orgsWithStats = (userOrgs as Array<any> | undefined)?.map((userOrg: any) => ({
|
||||
id: userOrg.organizations?.id || "",
|
||||
name: userOrg.organizations?.name || "",
|
||||
subscription_tier: userOrg.organizations?.subscription_tier || "free",
|
||||
subscription_status: userOrg.organizations?.subscription_status || "active",
|
||||
created_at: userOrg.organizations?.created_at || "",
|
||||
member_count: memberCounts[String(userOrg.organization_id)] || 0,
|
||||
website_count: websiteCounts[String(userOrg.organization_id)] || 0,
|
||||
user_role: userOrg.role || "member",
|
||||
})).filter((org: any) => org.id) || [];
|
||||
|
||||
return NextResponse.json({ organizations: orgsWithStats });
|
||||
} catch (error) {
|
||||
console.error("Error in organization GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, name, userId } = await request.json();
|
||||
|
||||
if (!id || !name || !userId) {
|
||||
return NextResponse.json({ error: "ID, name, and user ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has permission to update this organization
|
||||
const { data: userOrg, error: permissionError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", id)
|
||||
.single();
|
||||
|
||||
if (permissionError || !userOrg) {
|
||||
return NextResponse.json({ error: "Organization not found or access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner") {
|
||||
return NextResponse.json({ error: "Only organization owners can update organizations" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Update organization
|
||||
const { data: org, error: updateError } = await getSupabaseAdmin()
|
||||
.from("organizations")
|
||||
.update({ name })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating organization:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ organization: org });
|
||||
} catch (error) {
|
||||
console.error("Error in organization PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({ error: "ID and user ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this organization
|
||||
const { data: userOrg, error: permissionError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", id)
|
||||
.single();
|
||||
|
||||
if (permissionError || !userOrg) {
|
||||
return NextResponse.json({ error: "Organization not found or access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner") {
|
||||
return NextResponse.json({ error: "Only organization owners can delete organizations" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete organization (CASCADE should handle related records)
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("organizations")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Error deleting organization:", deleteError);
|
||||
return NextResponse.json({ error: "Failed to delete organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in organization DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { url, deviceType = 'desktop' } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "URL is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Test the Lighthouse scanner directly
|
||||
const lighthouseUrl = process.env.LIGHTHOUSE_SERVICE_URL || 'http://localhost:5001';
|
||||
const response = await fetch(`${lighthouseUrl}/lighthouse`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lighthouse service responded with ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Lighthouse test error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Lighthouse test failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test the scanner worker health
|
||||
const lighthouseUrl = process.env.LIGHTHOUSE_SERVICE_URL || 'http://localhost:5001';
|
||||
const response = await fetch(`${lighthouseUrl}/health`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scanner worker health check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const health = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scannerWorker: health,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Health check failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test basic database connection
|
||||
const { data: websites, error: websitesError } = await supabase
|
||||
.from('websites')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
// Test scans table connection
|
||||
const { data: scans, error: scansError } = await supabase
|
||||
.from('scans')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
// Test users table connection
|
||||
const { data: users, error: usersError } = await supabase
|
||||
.from('users')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
database: {
|
||||
websites: {
|
||||
connected: !websitesError,
|
||||
error: websitesError?.message || null
|
||||
},
|
||||
scans: {
|
||||
connected: !scansError,
|
||||
error: scansError?.message || null
|
||||
},
|
||||
users: {
|
||||
connected: !usersError,
|
||||
error: usersError?.message || null
|
||||
}
|
||||
},
|
||||
message: "Database connection test completed"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database test error:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: "Database connection test failed"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
// Check auth state in API route
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
console.log("API route auth check:", {
|
||||
isAuthenticated: !!user,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
authError: authError?.message,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
isAuthenticated: !!user,
|
||||
userId: user?.id || null,
|
||||
authError: authError?.message || null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let validUrl: URL;
|
||||
try {
|
||||
validUrl = new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(validUrl.protocol)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only HTTP and HTTPS URLs are supported" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the website with a timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'CloudLense Website Validator/1.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: `Website returned ${response.status} ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get content type
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!contentType.includes('text/html')) {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'URL does not point to an HTML page',
|
||||
});
|
||||
}
|
||||
|
||||
// Parse HTML to extract metadata
|
||||
const html = await response.text();
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
// Extract meta description
|
||||
const descriptionMatch = html.match(/<meta[^>]+name=['"]description['"][^>]+content=['"]([^'"]*)['"]/i);
|
||||
const description = descriptionMatch ? descriptionMatch[1].trim() : '';
|
||||
|
||||
// Try to get favicon
|
||||
const faviconMatch = html.match(/<link[^>]+rel=['"](?:icon|shortcut icon)['"][^>]+href=['"]([^'"]*)['"]/i);
|
||||
let favicon = faviconMatch ? faviconMatch[1] : '/favicon.ico';
|
||||
|
||||
// Convert relative favicon URL to absolute
|
||||
if (favicon && !favicon.startsWith('http')) {
|
||||
favicon = new URL(favicon, url).href;
|
||||
}
|
||||
|
||||
// Validate favicon exists
|
||||
let validFavicon: string | undefined;
|
||||
try {
|
||||
const faviconResponse = await fetch(favicon, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (faviconResponse.ok) {
|
||||
validFavicon = favicon;
|
||||
}
|
||||
} catch {
|
||||
// If favicon fails, try the Google favicon service
|
||||
validFavicon = `https://www.google.com/s2/favicons?domain=${validUrl.hostname}&sz=32`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isValid: true,
|
||||
title: title || validUrl.hostname,
|
||||
description: description || '',
|
||||
favicon: validFavicon,
|
||||
hostname: validUrl.hostname,
|
||||
protocol: validUrl.protocol,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'Website took too long to respond (timeout)',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'Unable to connect to website. Please check the URL and try again.',
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Website validation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { websiteId, url, changeType, contentHash, metadata } = body;
|
||||
|
||||
if (!websiteId || !url) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID and URL are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Structured log for production visibility
|
||||
console.info(JSON.stringify({
|
||||
level: 'info',
|
||||
event: 'webhook_website_change_received',
|
||||
websiteId,
|
||||
changeType: changeType || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Verify the webhook signature if needed
|
||||
const signature = request.headers.get('x-webhook-signature');
|
||||
if (process.env.WEBHOOK_SECRET && signature) {
|
||||
// Add webhook signature verification here if needed
|
||||
// const isValid = verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET);
|
||||
// if (!isValid) {
|
||||
// return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
// }
|
||||
}
|
||||
|
||||
// Get website details
|
||||
const { getSupabaseAdmin } = await import("@/lib/admin");
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const { data: website, error: websiteError } = await supabase
|
||||
.from('websites')
|
||||
.select(`
|
||||
id,
|
||||
organization_id,
|
||||
organizations!inner (
|
||||
subscription_tier
|
||||
)
|
||||
`)
|
||||
.eq('id', websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check subscription limits
|
||||
const { canScan, limits } = await lighthouseScanner.checkSubscriptionLimits(
|
||||
String(website.organization_id)
|
||||
);
|
||||
|
||||
if (!canScan) {
|
||||
console.warn(JSON.stringify({
|
||||
level: 'warn',
|
||||
event: 'subscription_limit_exceeded',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Subscription limit exceeded - scan skipped",
|
||||
limits,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if change detection is enabled for this subscription
|
||||
const subscriptionLimits = getSubscriptionLimits(String((website as any).organizations?.subscription_tier || 'free'));
|
||||
if (!subscriptionLimits.changeDetectionEnabled) {
|
||||
console.warn(JSON.stringify({
|
||||
level: 'warn',
|
||||
event: 'change_detection_disabled',
|
||||
tier: String((website as any).organizations?.subscription_tier || 'unknown'),
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Change detection not available for this subscription tier",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create the page record
|
||||
let pageId: string;
|
||||
const { data: existingPage } = await supabase
|
||||
.from('pages')
|
||||
.select('id, content_hash')
|
||||
.eq('website_id', websiteId)
|
||||
.eq('url', url)
|
||||
.single();
|
||||
|
||||
if (existingPage) {
|
||||
pageId = String((existingPage as any).id);
|
||||
|
||||
// Update the page with new content hash
|
||||
await supabase
|
||||
.from('pages')
|
||||
.update({
|
||||
content_hash: contentHash,
|
||||
last_seen_at: new Date().toISOString(),
|
||||
metadata: metadata || {},
|
||||
})
|
||||
.eq('id', pageId);
|
||||
} else {
|
||||
// Create new page record
|
||||
const { data: newPage, error: createError } = await supabase
|
||||
.from('pages')
|
||||
.insert({
|
||||
website_id: websiteId,
|
||||
url,
|
||||
path: new URL(url).pathname,
|
||||
content_hash: contentHash,
|
||||
title: metadata?.title || 'Unknown Page',
|
||||
is_active: true,
|
||||
metadata: metadata || {},
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (createError || !newPage) {
|
||||
throw new Error(`Failed to create page record: ${createError?.message}`);
|
||||
}
|
||||
|
||||
pageId = String((newPage as any).id);
|
||||
}
|
||||
|
||||
// Trigger a high-priority scan due to changes
|
||||
const scanConfig = {
|
||||
websiteId,
|
||||
pageId,
|
||||
deviceType: 'desktop' as const,
|
||||
categories: ['performance', 'accessibility', 'seo', 'best_practices'] as (
|
||||
'performance' | 'accessibility' | 'seo' | 'best_practices'
|
||||
)[],
|
||||
priority: 'high' as const,
|
||||
triggeredBy: 'change_detection' as const,
|
||||
};
|
||||
|
||||
const result = await lighthouseScanner.performScan(scanConfig);
|
||||
|
||||
// Log the change detection
|
||||
await supabase
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
website_id: websiteId,
|
||||
action: 'change_detected',
|
||||
entity_type: 'page',
|
||||
entity_id: pageId,
|
||||
changes: {
|
||||
change_type: changeType || 'content_update',
|
||||
url,
|
||||
content_hash: contentHash,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.info(JSON.stringify({
|
||||
level: 'info',
|
||||
event: 'change_detection_scan_completed',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scanId: result.scanId,
|
||||
message: "Change detection scan triggered successfully",
|
||||
metrics: result.metrics,
|
||||
});
|
||||
} else {
|
||||
console.error(JSON.stringify({
|
||||
level: 'error',
|
||||
event: 'change_detection_scan_failed',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing website change webhook: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription limits based on tier
|
||||
*/
|
||||
function getSubscriptionLimits(tier: string) {
|
||||
switch (tier) {
|
||||
case 'free':
|
||||
return {
|
||||
changeDetectionEnabled: false,
|
||||
};
|
||||
case 'starter':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
case 'professional':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
case 'enterprise':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
changeDetectionEnabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
const { id: websiteId } = await context.params;
|
||||
|
||||
if (!userId || !websiteId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, crawl_settings")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has access to this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get scan configurations
|
||||
const { data: scanConfigs, error: scanError } = await getSupabaseAdmin()
|
||||
.from("scan_configurations")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
// Get alert configurations
|
||||
const { data: alertConfigs, error: alertError } = await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
if (scanError || alertError) {
|
||||
console.error("Error fetching configurations:", { scanError, alertError });
|
||||
}
|
||||
|
||||
// Structure the response
|
||||
const crawl = (website as any).crawl_settings || {};
|
||||
const settings = {
|
||||
scan: {
|
||||
scanInterval: crawl.crawl_frequency === "hourly" ? 60 :
|
||||
crawl.crawl_frequency === "daily" ? 1440 : 60,
|
||||
maxPages: crawl.max_pages || 100,
|
||||
maxDepth: crawl.max_depth || 3,
|
||||
userAgent: crawl.user_agent || "",
|
||||
excludePatterns: crawl.exclude_patterns || ["/admin/*", "/api/*"],
|
||||
includePatterns: crawl.include_patterns || ["/*"],
|
||||
respectRobotsTxt: crawl.respect_robots_txt !== false,
|
||||
followRedirects: crawl.follow_redirects !== false,
|
||||
maxConcurrentRequests: crawl.max_concurrent_requests || 3,
|
||||
},
|
||||
alerts: {
|
||||
performanceThreshold: 90,
|
||||
seoThreshold: 90,
|
||||
accessibilityThreshold: 90,
|
||||
uptimeThreshold: 99,
|
||||
notificationEmail: "",
|
||||
slackWebhook: "",
|
||||
enableEmailAlerts: true,
|
||||
enableSlackAlerts: false,
|
||||
alertFrequency: "immediate" as const,
|
||||
},
|
||||
scanConfigurations: scanConfigs || [],
|
||||
alertConfigurations: alertConfigs || [],
|
||||
};
|
||||
|
||||
// Override with actual alert settings if they exist
|
||||
if (alertConfigs && alertConfigs.length > 0) {
|
||||
alertConfigs.forEach((config: any) => {
|
||||
switch (config.metric) {
|
||||
case "performance":
|
||||
settings.alerts.performanceThreshold = Number(config.threshold) || settings.alerts.performanceThreshold;
|
||||
break;
|
||||
case "seo":
|
||||
settings.alerts.seoThreshold = Number(config.threshold) || settings.alerts.seoThreshold;
|
||||
break;
|
||||
case "accessibility":
|
||||
settings.alerts.accessibilityThreshold = Number(config.threshold) || settings.alerts.accessibilityThreshold;
|
||||
break;
|
||||
case "uptime":
|
||||
settings.alerts.uptimeThreshold = Number(config.threshold) || settings.alerts.uptimeThreshold;
|
||||
break;
|
||||
}
|
||||
settings.alerts.enableEmailAlerts = config.notification_channels?.includes("email") || false;
|
||||
settings.alerts.enableSlackAlerts = config.notification_channels?.includes("slack") || false;
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
console.error("Error in website settings GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId, settingsType, settings } = await request.json();
|
||||
const { id: websiteId } = await context.params;
|
||||
|
||||
if (!userId || !websiteId || !settingsType) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID, user ID, and settings type are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, crawl_settings")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to update this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (settingsType === "scan") {
|
||||
// Update crawl settings
|
||||
const updatedCrawlSettings = {
|
||||
...(website as any).crawl_settings || {},
|
||||
max_pages: settings.maxPages,
|
||||
max_depth: settings.maxDepth,
|
||||
user_agent: settings.userAgent,
|
||||
exclude_patterns: settings.excludePatterns,
|
||||
include_patterns: settings.includePatterns,
|
||||
respect_robots_txt: settings.respectRobotsTxt,
|
||||
follow_redirects: settings.followRedirects,
|
||||
max_concurrent_requests: settings.maxConcurrentRequests,
|
||||
crawl_frequency: settings.scanInterval === 60 ? "hourly" :
|
||||
settings.scanInterval === 1440 ? "daily" : "hourly",
|
||||
};
|
||||
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ crawl_settings: updatedCrawlSettings })
|
||||
.eq("id", websiteId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating scan settings:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update scan settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Update or create scan configurations
|
||||
const { error: scanConfigError } = await getSupabaseAdmin()
|
||||
.from("scan_configurations")
|
||||
.upsert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
category: "performance",
|
||||
interval_minutes: settings.scanInterval,
|
||||
is_active: true,
|
||||
priority: 1,
|
||||
settings: {
|
||||
lighthouse: true,
|
||||
uptime: true,
|
||||
max_pages: settings.maxPages,
|
||||
max_depth: settings.maxDepth,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (scanConfigError) {
|
||||
console.error("Error updating scan configuration:", scanConfigError);
|
||||
}
|
||||
|
||||
} else if (settingsType === "alerts") {
|
||||
// Update alert configurations
|
||||
const alertTypes = [
|
||||
{ metric: "performance", threshold: settings.performanceThreshold },
|
||||
{ metric: "seo", threshold: settings.seoThreshold },
|
||||
{ metric: "accessibility", threshold: settings.accessibilityThreshold },
|
||||
{ metric: "uptime", threshold: settings.uptimeThreshold },
|
||||
];
|
||||
|
||||
const notificationChannels: string[] = [];
|
||||
if (settings.enableEmailAlerts) notificationChannels.push("email");
|
||||
if (settings.enableSlackAlerts) notificationChannels.push("slack");
|
||||
|
||||
const alertConfigs = alertTypes.map(alert => ({
|
||||
website_id: websiteId,
|
||||
metric: alert.metric,
|
||||
threshold: alert.threshold,
|
||||
comparison: "less_than",
|
||||
notification_channels: notificationChannels,
|
||||
is_active: true,
|
||||
alert_frequency: settings.alertFrequency,
|
||||
email_address: settings.notificationEmail || null,
|
||||
slack_webhook: settings.slackWebhook || null,
|
||||
}));
|
||||
|
||||
// Delete existing configurations and insert new ones
|
||||
await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.delete()
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
const { error: alertError } = await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.insert(alertConfigs);
|
||||
|
||||
if (alertError) {
|
||||
console.error("Error updating alert configurations:", alertError);
|
||||
return NextResponse.json({ error: "Failed to update alert settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${settingsType} settings updated successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in website settings PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { action, websiteIds, userId, updates } = await request.json();
|
||||
|
||||
if (!action || !websiteIds || !Array.isArray(websiteIds) || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Action, website IDs array, and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to all websites
|
||||
const { data: websites, error: websitesError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, organization_id, name")
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (websitesError || !websites) {
|
||||
return NextResponse.json({ error: "Failed to fetch websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Check if all websites belong to user's organization
|
||||
const { data: userOrg, error: userError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (userError || !userOrg) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const unauthorizedWebsites = websites.filter(w => w.organization_id !== userOrg.organization_id);
|
||||
if (unauthorizedWebsites.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "Access denied to some websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
let results: any[] = [];
|
||||
|
||||
switch (action) {
|
||||
case "activate":
|
||||
const { error: activateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ is_active: true })
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (activateError) {
|
||||
return NextResponse.json({ error: "Failed to activate websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "activated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "deactivate":
|
||||
const { error: deactivateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ is_active: false })
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (deactivateError) {
|
||||
return NextResponse.json({ error: "Failed to deactivate websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "deactivated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
// Only owners and admins can delete websites
|
||||
if (userOrg.role !== "owner" && userOrg.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can delete websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.delete()
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: "Failed to delete websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "deleted"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "update":
|
||||
if (!updates || typeof updates !== "object") {
|
||||
return NextResponse.json({
|
||||
error: "Updates object is required for update action"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (updates.crawl_settings) updateData.crawl_settings = updates.crawl_settings;
|
||||
if (updates.is_active !== undefined) updateData.is_active = updates.is_active;
|
||||
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update(updateData)
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: "Failed to update websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "updated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "scan":
|
||||
// Trigger scans for all websites
|
||||
const scanPromises = websiteIds.map(async (websiteId: string) => {
|
||||
try {
|
||||
// Insert scan request
|
||||
const { error: scanError } = await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
status: "pending",
|
||||
triggered_by: userId,
|
||||
scan_type: "manual",
|
||||
}
|
||||
]);
|
||||
|
||||
if (scanError) {
|
||||
console.error(`Failed to trigger scan for website ${websiteId}:`, scanError);
|
||||
return { id: websiteId, status: "scan_failed", error: scanError.message };
|
||||
}
|
||||
|
||||
return { id: websiteId, status: "scan_triggered" };
|
||||
} catch (error) {
|
||||
console.error(`Error triggering scan for website ${websiteId}:`, error);
|
||||
return { id: websiteId, status: "scan_failed" };
|
||||
}
|
||||
});
|
||||
|
||||
const scanResults = await Promise.all(scanPromises);
|
||||
results = websites.map(w => {
|
||||
const scanResult = scanResults.find(r => r.id === w.id);
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: scanResult?.status || "scan_failed",
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({
|
||||
error: "Invalid action. Supported actions: activate, deactivate, delete, update, scan"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action,
|
||||
results,
|
||||
message: `Successfully ${action}d ${results.length} website(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in websites bulk operation:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!organizationId || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Organization ID and User ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get websites with their latest scan information
|
||||
const { data: websites, error: websitesError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
is_active,
|
||||
created_at,
|
||||
settings
|
||||
`)
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (websitesError) {
|
||||
console.error("Error fetching websites:", websitesError);
|
||||
return NextResponse.json({ error: "Failed to fetch websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get additional stats for each website
|
||||
const websiteIds = websites?.map(w => w.id) || [];
|
||||
|
||||
if (websiteIds.length > 0) {
|
||||
const [pagesData, scansData] = await Promise.all([
|
||||
getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("website_id")
|
||||
.in("website_id", websiteIds),
|
||||
|
||||
getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.select("website_id, status, lighthouse_score")
|
||||
.in("website_id", websiteIds)
|
||||
.order("created_at", { ascending: false })
|
||||
]);
|
||||
|
||||
// Count pages per website
|
||||
const pagesCounts = (pagesData.data as Array<{ website_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, page) => {
|
||||
const key = String(page.website_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
// Get latest scan per website
|
||||
const latestScans = (scansData.data as Array<{ website_id: string }> | null | undefined)?.reduce((acc: Record<string, any>, scan: any) => {
|
||||
const key = String(scan.website_id);
|
||||
if (!acc[key]) {
|
||||
acc[key] = scan;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>) || {};
|
||||
|
||||
// Add stats to websites
|
||||
const websitesWithStats = (websites as Array<any>).map((website: any) => ({
|
||||
...website,
|
||||
stats: {
|
||||
pagesCount: pagesCounts[String(website.id)] || 0,
|
||||
latestScan: latestScans[String(website.id)] || null,
|
||||
}
|
||||
}));
|
||||
|
||||
return NextResponse.json({ websites: websitesWithStats });
|
||||
}
|
||||
|
||||
return NextResponse.json({ websites: websites || [] });
|
||||
} catch (error) {
|
||||
console.error("Error in websites GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, base_url, organizationId, userId, crawl_settings } = await request.json();
|
||||
|
||||
if (!name || !base_url || !organizationId || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Name, URL, organization ID, and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has permission to add websites to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
let normalizedUrl = base_url;
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
normalizedUrl = normalizedUrl.replace(/\/+$/, "");
|
||||
|
||||
// Default crawl settings
|
||||
const defaultCrawlSettings = {
|
||||
max_depth: 3,
|
||||
max_pages: 100,
|
||||
exclude_patterns: ["/admin/*", "/api/*", "*.pdf", "*.jpg", "*.png"],
|
||||
include_patterns: ["/*"],
|
||||
respect_robots_txt: true,
|
||||
crawl_frequency: "daily",
|
||||
...crawl_settings
|
||||
};
|
||||
|
||||
// Create website
|
||||
const { data: website, error: createError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.insert([
|
||||
{
|
||||
name,
|
||||
base_url: normalizedUrl,
|
||||
organization_id: organizationId,
|
||||
is_active: true,
|
||||
crawl_settings: defaultCrawlSettings,
|
||||
scan_status: "pending"
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
console.error("Error creating website:", createError);
|
||||
return NextResponse.json({ error: "Failed to create website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ website });
|
||||
} catch (error) {
|
||||
console.error("Error in websites POST:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, name, base_url, is_active, crawl_settings, userId } = await request.json();
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to update this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: any = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (base_url !== undefined) {
|
||||
let normalizedUrl = base_url;
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
updateData.base_url = normalizedUrl.replace(/\/+$/, "");
|
||||
}
|
||||
if (is_active !== undefined) updateData.is_active = is_active;
|
||||
if (crawl_settings !== undefined) updateData.crawl_settings = crawl_settings;
|
||||
|
||||
// Update website
|
||||
const { data: updatedWebsite, error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update(updateData)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating website:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ website: updatedWebsite });
|
||||
} catch (error) {
|
||||
console.error("Error in websites PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, name")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to delete this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner" && userOrg.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can delete websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete website (CASCADE should handle related records)
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Error deleting website:", deleteError);
|
||||
return NextResponse.json({ error: "Failed to delete website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Website "${website.name}" deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in websites DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthCallback = async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(window.location.href);
|
||||
const token = searchParams.get("token");
|
||||
const type = searchParams.get("type");
|
||||
|
||||
if (token && type === "email_verification") {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash: token,
|
||||
type: "email",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Redirect to dashboard after successful verification
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during auth callback:", error);
|
||||
router.push("/auth?error=verification_failed");
|
||||
}
|
||||
};
|
||||
|
||||
handleAuthCallback();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-r from-blue-500 to-purple-500">
|
||||
<div className="bg-white p-8 rounded-lg shadow-xl text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Verifying your email...
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Please wait while we complete the process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FixAccount } from "@/components/auth/FixAccount";
|
||||
|
||||
export default function FixAccountPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<FixAccount />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { AuthForm } from "@/components/auth/AuthForm";
|
||||
import { Shield, ArrowLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function AuthPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const source = searchParams.get("source");
|
||||
const email = searchParams.get("email");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.02]" />
|
||||
|
||||
{mounted && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute top-0 -left-4 w-[500px] h-[500px] bg-gradient-to-br from-blue-400/20 to-indigo-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="absolute bottom-0 -right-4 w-[500px] h-[500px] bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="relative w-full max-w-lg mx-auto"
|
||||
>
|
||||
{/* Logo and name above the card */}
|
||||
<div className="absolute top-[-40px] left-1/2 transform -translate-x-1/2 flex items-center space-x-2">
|
||||
<Shield className="h-8 w-8 text-blue-600" />
|
||||
<span className="text-2xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||
CloudLense
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Glass card effect */}
|
||||
<div className="relative backdrop-blur-xl bg-white/80 rounded-2xl shadow-xl border border-white/20 overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="px-8 pt-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-4 cursor-pointer transition-transform transform hover:scale-110"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
Back to Home
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 mb-4"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{source === "hero" ? "Get Started" : "Welcome"}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{source === "hero"
|
||||
? "Set up your account in seconds"
|
||||
: "Sign in to continue to your dashboard"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
<div className="p-8">
|
||||
<AuthForm initialEmail={email} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="px-8 pb-8 text-center"
|
||||
>
|
||||
<p className="text-sm text-gray-500">
|
||||
By continuing, you agree to our{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-100 rounded-2xl -m-2"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-50 rounded-2xl -m-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/forms/Form";
|
||||
import { Input } from "@/components/ui/forms/Input";
|
||||
import { Shield, Key, Loader2, Check, ArrowLeft } from "lucide-react";
|
||||
import { ErrorFeedback } from "@/components/ui/ErrorFeedback";
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Password must be at least 6 characters"),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const form = useForm<z.infer<typeof resetPasswordSchema>>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Check if we have the proper reset token in the URL
|
||||
const accessToken = searchParams.get('access_token');
|
||||
const refreshToken = searchParams.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError("Invalid or expired reset link. Please request a new password reset.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the session with the tokens from the URL
|
||||
supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
const handlePasswordReset = async (data: z.infer<typeof resetPasswordSchema>) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Redirect to dashboard after successful reset
|
||||
setTimeout(() => {
|
||||
router.push("/dashboard");
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("Failed to reset password. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.02]" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute top-0 -left-4 w-[500px] h-[500px] bg-gradient-to-br from-blue-400/20 to-indigo-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="absolute bottom-0 -right-4 w-[500px] h-[500px] bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="relative w-full max-w-lg mx-auto"
|
||||
>
|
||||
{/* Logo and name above the card */}
|
||||
<div className="absolute top-[-40px] left-1/2 transform -translate-x-1/2 flex items-center space-x-2">
|
||||
<Shield className="h-8 w-8 text-blue-600" />
|
||||
<span className="text-2xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||
CloudLense
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Glass card effect */}
|
||||
<div className="relative backdrop-blur-xl bg-white/80 rounded-2xl shadow-xl border border-white/20 overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="px-8 pt-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-4 cursor-pointer transition-transform transform hover:scale-110"
|
||||
onClick={() => router.push("/auth")}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
Back to Login
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 mb-4"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Reset Your Password
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Enter your new password below
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
<div className="p-8">
|
||||
{success ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center space-y-4"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Check className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Password Reset Successfully</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Your password has been updated. Redirecting to dashboard...
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handlePasswordReset)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transition-all duration-300"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Update Password"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorFeedback
|
||||
title="Password Reset Failed"
|
||||
message={error}
|
||||
details="Please try requesting a new password reset link."
|
||||
severity="error"
|
||||
onDismiss={() => setError("")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-100 rounded-2xl -m-2"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-50 rounded-2xl -m-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { useDashboardData } from "@/hooks/useDashboardData";
|
||||
import {
|
||||
Users,
|
||||
Building2,
|
||||
Globe,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Search,
|
||||
Trash2,
|
||||
UserX,
|
||||
UserCheck,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SystemStats {
|
||||
users: number;
|
||||
organizations: number;
|
||||
websites: number;
|
||||
scans: { total: number; today: number; thisMonth: number; byStatus: Record<string, number> };
|
||||
alerts: { active: number };
|
||||
uptime: { checksLast24h: number; overallUptime: number };
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
last_sign_in_at: string | null;
|
||||
organization_id: string | null;
|
||||
memberships: Array<{
|
||||
role: string;
|
||||
organizations: { name: string; subscription_tier: string } | null;
|
||||
}>;
|
||||
totalScans: number;
|
||||
}
|
||||
|
||||
interface AdminOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
subscription_tier: string;
|
||||
subscription_status: string;
|
||||
created_at: string;
|
||||
memberCount: number;
|
||||
websiteCount: number;
|
||||
scanCount: number;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { userDetails } = useDashboardData({ requireOrganization: false });
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "users" | "organizations">("overview");
|
||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [orgs, setOrgs] = useState<AdminOrg[]>([]);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/stats");
|
||||
if (res.ok) setStats(await res.json());
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users?search=${encodeURIComponent(userSearch)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsers(data.users);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch users:", e);
|
||||
}
|
||||
}, [userSearch]);
|
||||
|
||||
const fetchOrgs = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/organizations");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOrgs(data.organizations);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch orgs:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchStats(), fetchUsers(), fetchOrgs()]).finally(() => setLoading(false));
|
||||
}, [fetchStats, fetchUsers, fetchOrgs]);
|
||||
|
||||
// User actions
|
||||
const handleUserAction = async (userId: string, action: string, value?: Record<string, unknown>) => {
|
||||
setActionLoading(userId);
|
||||
try {
|
||||
if (action === "delete") {
|
||||
await fetch("/api/admin/users", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
} else {
|
||||
await fetch("/api/admin/users", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, action, value }),
|
||||
});
|
||||
}
|
||||
await fetchUsers();
|
||||
await fetchStats();
|
||||
} catch (e) {
|
||||
console.error("Action failed:", e);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrgTierChange = async (orgId: string, tier: string) => {
|
||||
setActionLoading(orgId);
|
||||
try {
|
||||
await fetch("/api/admin/organizations", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
organizationId: orgId,
|
||||
updates: { subscription_tier: tier },
|
||||
}),
|
||||
});
|
||||
await fetchOrgs();
|
||||
await fetchStats();
|
||||
} catch (e) {
|
||||
console.error("Tier change failed:", e);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Role check
|
||||
const isAdmin =
|
||||
userDetails?.role === "owner" || userDetails?.role === "admin";
|
||||
|
||||
if (!isAdmin && !loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<Shield className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-700">Access Denied</h2>
|
||||
<p className="text-gray-500 mt-2">You need admin or owner permissions to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">System overview and management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
{ id: "overview" as const, label: "Overview", icon: BarChart3 },
|
||||
{ id: "users" as const, label: "Users", icon: Users },
|
||||
{ id: "organizations" as const, label: "Organizations", icon: Building2 },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 py-3 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && stats && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Users} label="Total Users" value={stats.users} color="blue" />
|
||||
<StatCard icon={Building2} label="Organizations" value={stats.organizations} color="purple" />
|
||||
<StatCard icon={Globe} label="Websites" value={stats.websites} color="green" />
|
||||
<StatCard icon={Activity} label="Scans Today" value={stats.scans.today} color="orange" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Scans</h3>
|
||||
<p className="text-3xl font-bold mt-2">{stats.scans.total.toLocaleString()}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{stats.scans.thisMonth} this month</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Active Alerts</h3>
|
||||
<p className={`text-3xl font-bold mt-2 ${stats.alerts.active > 0 ? "text-red-600" : "text-green-600"}`}>
|
||||
{stats.alerts.active}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{stats.alerts.active === 0 ? "All clear" : "Needs attention"}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Uptime (24h)</h3>
|
||||
<p className={`text-3xl font-bold mt-2 ${stats.uptime.overallUptime >= 99 ? "text-green-600" : stats.uptime.overallUptime >= 95 ? "text-yellow-600" : "text-red-600"}`}>
|
||||
{stats.uptime.overallUptime}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{stats.uptime.checksLast24h} checks performed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === "users" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name or email..."
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchUsers()}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">User</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Organization</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Role</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Tier</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Joined</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{user.name || "—"}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{user.memberships?.[0]?.organizations?.name || "None"}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
|
||||
user.memberships?.[0]?.role === "owner"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: user.memberships?.[0]?.role === "admin"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}>
|
||||
{user.memberships?.[0]?.role || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<TierBadge tier={user.memberships?.[0]?.organizations?.subscription_tier || "free"} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleUserAction(user.id, "deactivate")}
|
||||
disabled={actionLoading === user.id}
|
||||
className="p-1 text-gray-400 hover:text-orange-600"
|
||||
title="Deactivate user"
|
||||
>
|
||||
<UserX className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUserAction(user.id, "activate")}
|
||||
disabled={actionLoading === user.id}
|
||||
className="p-1 text-gray-400 hover:text-green-600"
|
||||
title="Activate user"
|
||||
>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Permanently delete this user?")) {
|
||||
handleUserAction(user.id, "delete");
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading === user.id}
|
||||
className="p-1 text-gray-400 hover:text-red-600"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizations Tab */}
|
||||
{activeTab === "organizations" && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Organization</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Members</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Websites</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Scans</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Tier</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{orgs.map((org) => (
|
||||
<tr key={org.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-medium text-gray-900">{org.name}</p>
|
||||
<p className="text-xs text-gray-400">{org.id.slice(0, 8)}...</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{org.memberCount}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{org.websiteCount}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{org.scanCount}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={org.subscription_tier || "free"}
|
||||
onChange={(e) => handleOrgTierChange(org.id, e.target.value)}
|
||||
disabled={actionLoading === org.id}
|
||||
className="appearance-none bg-transparent pr-6 py-1 text-sm font-medium border rounded-md px-2 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="starter">Starter</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-1 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(org.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orgs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
No organizations found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
blue: "bg-blue-50 text-blue-600",
|
||||
purple: "bg-purple-50 text-purple-600",
|
||||
green: "bg-green-50 text-green-600",
|
||||
orange: "bg-orange-50 text-orange-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6 flex items-center gap-4">
|
||||
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-bold">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierBadge({ tier }: { tier: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
free: "bg-gray-100 text-gray-700",
|
||||
starter: "bg-blue-100 text-blue-700",
|
||||
professional: "bg-purple-100 text-purple-700",
|
||||
enterprise: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${colors[tier] || colors.free}`}>
|
||||
{tier}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Mail,
|
||||
Smartphone,
|
||||
Settings,
|
||||
Clock,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
Plus,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
type: "downtime" | "performance" | "error" | "ssl" | "maintenance";
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
title: string;
|
||||
message: string;
|
||||
website_name: string;
|
||||
website_url: string;
|
||||
status: "active" | "resolved" | "acknowledged";
|
||||
created_at: string;
|
||||
resolved_at?: string;
|
||||
acknowledged_at?: string;
|
||||
}
|
||||
|
||||
interface AlertRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "downtime" | "performance" | "error_rate";
|
||||
condition: string;
|
||||
threshold: number;
|
||||
enabled: boolean;
|
||||
notification_methods: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { userDetails } = useAuth();
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [alertRules, setAlertRules] = useState<AlertRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"alerts" | "rules">("alerts");
|
||||
const [processingAlert, setProcessingAlert] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.organization_id) {
|
||||
loadAlertsData();
|
||||
}
|
||||
}, [userDetails]);
|
||||
|
||||
const loadAlertsData = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load alerts
|
||||
const { data: alertsData, error: alertsError } = await supabase
|
||||
.from("alerts")
|
||||
.select(`
|
||||
id,
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
status,
|
||||
created_at,
|
||||
resolved_at,
|
||||
acknowledged_at,
|
||||
websites!inner (
|
||||
name,
|
||||
base_url,
|
||||
organization_id
|
||||
)
|
||||
`)
|
||||
.eq("websites.organization_id", userDetails.organization_id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
const formattedAlerts: Alert[] = alertsData?.map((alert: any) => ({
|
||||
id: alert.id,
|
||||
type: alert.type,
|
||||
severity: alert.severity,
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
website_name: alert.websites.name,
|
||||
website_url: alert.websites.base_url,
|
||||
status: alert.status,
|
||||
created_at: alert.created_at,
|
||||
resolved_at: alert.resolved_at,
|
||||
acknowledged_at: alert.acknowledged_at,
|
||||
})) || [];
|
||||
|
||||
setAlerts(formattedAlerts);
|
||||
|
||||
// Load alert rules
|
||||
const { data: rulesData, error: rulesError } = await supabase
|
||||
.from("alert_rules")
|
||||
.select("*")
|
||||
.eq("organization_id", userDetails.organization_id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (rulesError) throw rulesError;
|
||||
setAlertRules(rulesData || []);
|
||||
|
||||
} catch (error) {
|
||||
const errorInfo = extractSupabaseErrorInfo(error);
|
||||
logError("Error loading alerts data", error, {
|
||||
organizationId: userDetails.organization_id,
|
||||
function: "loadAlertsData",
|
||||
supabaseError: errorInfo
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlertAction = async (alertId: string, action: "acknowledge" | "resolve") => {
|
||||
try {
|
||||
setProcessingAlert(alertId);
|
||||
|
||||
const updateData = action === "acknowledge"
|
||||
? { status: "acknowledged" as const, acknowledged_at: new Date().toISOString() }
|
||||
: { status: "resolved" as const, resolved_at: new Date().toISOString() };
|
||||
|
||||
const { error } = await supabase
|
||||
.from("alerts")
|
||||
.update(updateData)
|
||||
.eq("id", alertId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Update local state
|
||||
setAlerts(prev => prev.map(alert =>
|
||||
alert.id === alertId
|
||||
? { ...alert, ...updateData }
|
||||
: alert
|
||||
));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error ${action}ing alert:`, error);
|
||||
} finally {
|
||||
setProcessingAlert(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAlertRule = async (ruleId: string, enabled: boolean) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("alert_rules")
|
||||
.update({ enabled: !enabled })
|
||||
.eq("id", ruleId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setAlertRules(prev => prev.map(rule =>
|
||||
rule.id === ruleId
|
||||
? { ...rule, enabled: !enabled }
|
||||
: rule
|
||||
));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error toggling alert rule:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: string, severity: string) => {
|
||||
const iconClass = severity === "critical"
|
||||
? "text-red-500"
|
||||
: severity === "high"
|
||||
? "text-orange-500"
|
||||
: severity === "medium"
|
||||
? "text-yellow-500"
|
||||
: "text-blue-500";
|
||||
|
||||
switch (type) {
|
||||
case "downtime":
|
||||
return <XCircle className={`w-4 h-4 ${iconClass}`} />;
|
||||
case "performance":
|
||||
return <TrendingDown className={`w-4 h-4 ${iconClass}`} />;
|
||||
case "error":
|
||||
return <AlertTriangle className={`w-4 h-4 ${iconClass}`} />;
|
||||
case "ssl":
|
||||
return <Settings className={`w-4 h-4 ${iconClass}`} />;
|
||||
default:
|
||||
return <Clock className={`w-4 h-4 ${iconClass}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "high":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
default:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "resolved":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "acknowledged":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-red-100 text-red-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const activeAlerts = alerts.filter(a => a.status === "active").length;
|
||||
const acknowledgedAlerts = alerts.filter(a => a.status === "acknowledged").length;
|
||||
const resolvedAlerts = alerts.filter(a => a.status === "resolved").length;
|
||||
const criticalAlerts = alerts.filter(a => a.severity === "critical" && a.status === "active").length;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Bell className="w-6 h-6" />
|
||||
Alerts & Notifications
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Monitor and manage alerts for your websites
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === "alerts" ? "default" : "outline"}
|
||||
onClick={() => setActiveTab("alerts")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
Alerts
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "rules" ? "default" : "outline"}
|
||||
onClick={() => setActiveTab("rules")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Rules
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Active Alerts</p>
|
||||
<p className="text-2xl font-bold text-red-600">{activeAlerts}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Critical</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{criticalAlerts}</p>
|
||||
</div>
|
||||
<XCircle className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Acknowledged</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{acknowledgedAlerts}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Resolved</p>
|
||||
<p className="text-2xl font-bold text-green-600">{resolvedAlerts}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "alerts" ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Alerts</h2>
|
||||
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert) => (
|
||||
<Card key={alert.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{getAlertIcon(alert.type, alert.severity)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{alert.title}</h3>
|
||||
<Badge className={getSeverityColor(alert.severity)}>
|
||||
{alert.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge className={getStatusColor(alert.status)}>
|
||||
{alert.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">{alert.message}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>{alert.website_name}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(alert.created_at).toLocaleString()}</span>
|
||||
{alert.resolved_at && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Resolved: {new Date(alert.resolved_at).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alert.status === "active" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAlertAction(alert.id, "acknowledge")}
|
||||
disabled={processingAlert === alert.id}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{processingAlert === alert.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3" />
|
||||
)}
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAlertAction(alert.id, "resolve")}
|
||||
disabled={processingAlert === alert.id}
|
||||
className="flex items-center gap-1 text-green-600 hover:text-green-700"
|
||||
>
|
||||
{processingAlert === alert.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
)}
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Bell className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No alerts found
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
When issues are detected with your websites, alerts will appear here
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Alert Rules</h2>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{alertRules.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alertRules.map((rule) => (
|
||||
<Card key={rule.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{rule.name}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{rule.type} {rule.condition} {rule.threshold}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{rule.notification_methods.includes("email") && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
Email
|
||||
</Badge>
|
||||
)}
|
||||
{rule.notification_methods.includes("sms") && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Smartphone className="w-3 h-3" />
|
||||
SMS
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge className={rule.enabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}>
|
||||
{rule.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleAlertRule(rule.id, rule.enabled)}
|
||||
>
|
||||
{rule.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Settings className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No alert rules configured
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Create alert rules to get notified about website issues
|
||||
</p>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Your First Rule
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Card, CardContent } from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function DiagnosticsPage() {
|
||||
const [results, setResults] = useState<any>({});
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const { user, userDetails } = useAuth();
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setIsRunning(true);
|
||||
const diagnosticResults: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
auth: { user, userDetails },
|
||||
};
|
||||
|
||||
try {
|
||||
// Test general permissions
|
||||
const { data: authTest, error: authError } =
|
||||
await supabase.auth.getUser();
|
||||
diagnosticResults.authTest = { data: authTest, error: authError };
|
||||
|
||||
// Test websites table access - select
|
||||
const { data: selectTest, error: selectError } = await supabase
|
||||
.from("websites")
|
||||
.select("*")
|
||||
.limit(5);
|
||||
diagnosticResults.selectTest = { data: selectTest, error: selectError };
|
||||
|
||||
// Test organizations table access
|
||||
const { data: orgTest, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("*")
|
||||
.limit(5);
|
||||
diagnosticResults.orgTest = { data: orgTest, error: orgError };
|
||||
|
||||
// Test insert (with immediate deletion to avoid clutter)
|
||||
const testName = `Test Website ${new Date().toISOString()}`;
|
||||
const { data: insertTest, error: insertError } = await supabase
|
||||
.from("websites")
|
||||
.insert([
|
||||
{
|
||||
name: testName,
|
||||
base_url: "https://example.com/test",
|
||||
organization_id: userDetails?.organization_id,
|
||||
is_active: true,
|
||||
},
|
||||
])
|
||||
.select();
|
||||
diagnosticResults.insertTest = { data: insertTest, error: insertError };
|
||||
|
||||
// If insert succeeded, delete the test website
|
||||
if (insertTest && insertTest.length > 0) {
|
||||
const { data: deleteTest, error: deleteError } = await supabase
|
||||
.from("websites")
|
||||
.delete()
|
||||
.eq("id", insertTest[0].id);
|
||||
diagnosticResults.deleteTest = { data: deleteTest, error: deleteError };
|
||||
}
|
||||
|
||||
setResults(diagnosticResults);
|
||||
} catch (error) {
|
||||
diagnosticResults.error = String(error);
|
||||
setResults(diagnosticResults);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Database Diagnostics</h1>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="mb-4">
|
||||
<Button onClick={runDiagnostics} disabled={isRunning}>
|
||||
{isRunning ? "Running Tests..." : "Run Diagnostics"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-semibold mb-2">Results:</h2>
|
||||
<pre className="bg-gray-100 p-4 rounded-md overflow-auto max-h-[600px] text-xs">
|
||||
{JSON.stringify(results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<div className="text-red-500 mb-4">
|
||||
<AlertCircle className="h-12 w-12" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Something went wrong!
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">{error.message}</p>
|
||||
<Button onClick={() => reset()}>Try again</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { LoadingSpinner } from "@/components/ui/feedback/LoadingSpinner";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Play,
|
||||
Pause,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
|
||||
|
||||
interface MonitoringStatus {
|
||||
id: string;
|
||||
website_name: string;
|
||||
website_url: string;
|
||||
is_monitoring: boolean;
|
||||
last_check: string;
|
||||
status: "up" | "down" | "warning";
|
||||
response_time: number;
|
||||
uptime_percentage: number;
|
||||
incidents_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface UptimeMetric {
|
||||
website_id: string;
|
||||
timestamp: string;
|
||||
status: "up" | "down" | "warning";
|
||||
response_time: number;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const { userDetails } = useAuth();
|
||||
const [websites, setWebsites] = useState<MonitoringStatus[]>([]);
|
||||
const [recentChecks, setRecentChecks] = useState<UptimeMetric[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.organization_id) {
|
||||
loadMonitoringData();
|
||||
// Set up real-time updates
|
||||
const interval = setInterval(loadMonitoringData, 30000); // Update every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [userDetails]);
|
||||
|
||||
const loadMonitoringData = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch websites with monitoring status
|
||||
const { data: websitesData, error: websitesError } = await supabase
|
||||
.from("websites")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
is_active,
|
||||
created_at,
|
||||
uptime_checks (
|
||||
id,
|
||||
status,
|
||||
response_time,
|
||||
checked_at,
|
||||
error_message
|
||||
)
|
||||
`)
|
||||
.eq("organization_id", userDetails.organization_id)
|
||||
.eq("is_active", true)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (websitesError) throw websitesError;
|
||||
|
||||
// Process monitoring data
|
||||
const monitoringData: MonitoringStatus[] = websitesData?.map((website: any) => {
|
||||
const checks = website.uptime_checks || [];
|
||||
const recentChecks = checks
|
||||
.filter((check: any) => {
|
||||
const checkDate = new Date(check.checked_at);
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return checkDate >= dayAgo;
|
||||
})
|
||||
.sort((a: any, b: any) => new Date(b.checked_at).getTime() - new Date(a.checked_at).getTime());
|
||||
|
||||
const latestCheck = recentChecks[0];
|
||||
const upChecks = recentChecks.filter((check: any) => check.status === "up").length;
|
||||
const totalChecks = recentChecks.length;
|
||||
const uptimePercentage = totalChecks > 0 ? Math.round((upChecks / totalChecks) * 100) : 0;
|
||||
const incidents = recentChecks.filter((check: any) => check.status === "down").length;
|
||||
|
||||
return {
|
||||
id: website.id,
|
||||
website_name: website.name,
|
||||
website_url: website.base_url,
|
||||
is_monitoring: true, // Assume monitoring is enabled for active websites
|
||||
last_check: latestCheck?.checked_at || website.created_at,
|
||||
status: latestCheck?.status || "warning",
|
||||
response_time: latestCheck?.response_time || 0,
|
||||
uptime_percentage: uptimePercentage,
|
||||
incidents_count: incidents,
|
||||
created_at: website.created_at,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
setWebsites(monitoringData);
|
||||
|
||||
// Get recent checks for the timeline
|
||||
const allChecks: UptimeMetric[] = [];
|
||||
websitesData?.forEach((website: any) => {
|
||||
const checks = website.uptime_checks || [];
|
||||
checks.slice(0, 10).forEach((check: any) => {
|
||||
allChecks.push({
|
||||
website_id: website.id,
|
||||
timestamp: check.checked_at,
|
||||
status: check.status,
|
||||
response_time: check.response_time,
|
||||
error_message: check.error_message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
allChecks.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
setRecentChecks(allChecks.slice(0, 20));
|
||||
|
||||
} catch (error) {
|
||||
const errorInfo = extractSupabaseErrorInfo(error);
|
||||
logError("Error loading monitoring data", error, {
|
||||
organizationId: userDetails.organization_id,
|
||||
function: "loadMonitoringData",
|
||||
supabaseError: errorInfo
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMonitoring = async (websiteId: string, currentStatus: boolean) => {
|
||||
try {
|
||||
setUpdating(websiteId);
|
||||
|
||||
// In a real implementation, you would update monitoring settings
|
||||
// For now, we'll just simulate the action
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Update local state
|
||||
setWebsites(prev => prev.map(website =>
|
||||
website.id === websiteId
|
||||
? { ...website, is_monitoring: !currentStatus }
|
||||
: website
|
||||
));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error toggling monitoring:", error);
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case "down":
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "down":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "warning":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getUptimeColor = (percentage: number) => {
|
||||
if (percentage >= 99) return "text-green-600";
|
||||
if (percentage >= 95) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const totalWebsites = websites.length;
|
||||
const activeMonitoring = websites.filter(w => w.is_monitoring).length;
|
||||
const upWebsites = websites.filter(w => w.status === "up").length;
|
||||
const downWebsites = websites.filter(w => w.status === "down").length;
|
||||
const avgUptime = websites.length > 0
|
||||
? Math.round(websites.reduce((sum, w) => sum + w.uptime_percentage, 0) / websites.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Activity className="w-6 h-6" />
|
||||
Website Monitoring
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Real-time uptime monitoring for your websites
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadMonitoringData}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Websites</p>
|
||||
<p className="text-2xl font-bold">{totalWebsites}</p>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Monitoring Active</p>
|
||||
<p className="text-2xl font-bold text-green-600">{activeMonitoring}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Websites Up</p>
|
||||
<p className="text-2xl font-bold text-green-600">{upWebsites}</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Average Uptime</p>
|
||||
<p className={`text-2xl font-bold ${getUptimeColor(avgUptime)}`}>
|
||||
{avgUptime}%
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monitoring Status */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Website Status</h2>
|
||||
|
||||
{websites.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{websites.map((website) => (
|
||||
<Card key={website.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(website.status)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{website.website_name}</h3>
|
||||
<p className="text-sm text-gray-500">{website.website_url}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<Badge className={getStatusColor(website.status)}>
|
||||
{website.status.toUpperCase()}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Last check: {new Date(website.last_check).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{website.response_time > 0 ? `${website.response_time}ms` : "—"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Response time</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${getUptimeColor(website.uptime_percentage)}`}>
|
||||
{website.uptime_percentage}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">24h uptime</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{website.incidents_count}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Incidents</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleMonitoring(website.id, website.is_monitoring)}
|
||||
disabled={updating === website.id}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{updating === website.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : website.is_monitoring ? (
|
||||
<Pause className="w-3 h-3" />
|
||||
) : (
|
||||
<Play className="w-3 h-3" />
|
||||
)}
|
||||
{website.is_monitoring ? "Pause" : "Start"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Activity className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No websites being monitored
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Add websites and enable monitoring to see uptime status here
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{recentChecks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{recentChecks.slice(0, 10).map((check, index) => {
|
||||
const website = websites.find(w => w.id === check.website_id);
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(check.status)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{website?.website_name || "Unknown"}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(check.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm">{check.response_time}ms</p>
|
||||
{check.error_message && (
|
||||
<p className="text-xs text-red-500">{check.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Input } from "@/components/ui/forms/Input";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@/components/ui/forms/Form";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/layout/Tabs";
|
||||
import { TeamManagement } from "@/components/dashboard/TeamManagement";
|
||||
import {
|
||||
Building2,
|
||||
Settings,
|
||||
Users,
|
||||
CreditCard,
|
||||
Shield,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
subscription_tier: string;
|
||||
subscription_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const organizationFormSchema = z.object({
|
||||
name: z.string().min(2, "Organization name must be at least 2 characters"),
|
||||
});
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { user, userDetails } = useAuth();
|
||||
const organizationId = params.id as string;
|
||||
|
||||
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
|
||||
const form = useForm<z.infer<typeof organizationFormSchema>>({
|
||||
resolver: zodResolver(organizationFormSchema),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationId && user) {
|
||||
loadOrganization();
|
||||
}
|
||||
}, [organizationId, user]);
|
||||
|
||||
const loadOrganization = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Check if user has access to this organization
|
||||
if (userDetails?.organization_id !== organizationId) {
|
||||
setError("Access denied. You don't have permission to access this organization.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("*")
|
||||
.eq("id", organizationId)
|
||||
.single();
|
||||
|
||||
if (orgError) {
|
||||
throw orgError;
|
||||
}
|
||||
|
||||
setOrganization(org);
|
||||
form.setValue("name", org.name);
|
||||
} catch (error) {
|
||||
console.error("Error loading organization:", error);
|
||||
setError("Failed to load organization details");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateOrganization = async (values: z.infer<typeof organizationFormSchema>) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ name: values.name })
|
||||
.eq("id", organizationId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setOrganization(prev => prev ? { ...prev, name: values.name } : null);
|
||||
setSuccess("Organization updated successfully!");
|
||||
} catch (error) {
|
||||
console.error("Error updating organization:", error);
|
||||
setError("Failed to update organization");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
if (!confirm("Are you sure you want to delete this organization? This action cannot be undone and will remove all associated data.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmText = prompt("Type 'DELETE' to confirm:");
|
||||
if (confirmText !== "DELETE") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.delete()
|
||||
.eq("id", organizationId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.push("/dashboard/organizations");
|
||||
} catch (error) {
|
||||
console.error("Error deleting organization:", error);
|
||||
setError("Failed to delete organization");
|
||||
}
|
||||
};
|
||||
|
||||
const getTierColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case "pro": return "text-blue-600 bg-blue-100";
|
||||
case "enterprise": return "text-purple-600 bg-purple-100";
|
||||
default: return "text-gray-600 bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
const canManageOrganization = userDetails?.role === "owner";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Organization Not Found
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
The organization you're looking for doesn't exist or you don't have access to it.
|
||||
</p>
|
||||
<Button onClick={() => router.push("/dashboard/organizations")}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Organizations
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-4xl mx-auto py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/dashboard/organizations")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{organization.name}</h1>
|
||||
<p className="text-gray-600 mt-1">Organization Settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence>
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3 mb-6"
|
||||
>
|
||||
<Check className="w-5 h-5 text-green-500" />
|
||||
<span className="text-green-800">{success}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSuccess("")}
|
||||
className="ml-auto"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3 mb-6"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800">{error}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setError("")}
|
||||
className="ml-auto"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Settings Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="general" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="members" className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="billing" className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Billing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="danger" className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Danger Zone
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General Settings */}
|
||||
<TabsContent value="general" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-blue-600" />
|
||||
Organization Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleUpdateOrganization)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Organization Name"
|
||||
disabled={!canManageOrganization || saving}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This name will be visible to all team members
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Organization Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Subscription Tier
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<span className={`inline-block px-3 py-1 rounded-full text-sm ${getTierColor(organization.subscription_tier)}`}>
|
||||
{organization.subscription_tier.charAt(0).toUpperCase() + organization.subscription_tier.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Created
|
||||
</label>
|
||||
<div className="mt-1 text-sm text-gray-900">
|
||||
{new Date(organization.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageOrganization && (
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Team Members */}
|
||||
<TabsContent value="members">
|
||||
<TeamManagement organizationId={organizationId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Billing */}
|
||||
<TabsContent value="billing">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-blue-600" />
|
||||
Billing & Subscription
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<CreditCard className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Billing Management
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Billing and subscription management features will be available soon.
|
||||
</p>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-blue-900">Current Plan</div>
|
||||
<div className="text-sm text-blue-700">
|
||||
{organization.subscription_tier.charAt(0).toUpperCase() + organization.subscription_tier.slice(1)} Plan
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" disabled>
|
||||
Manage Billing
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<TabsContent value="danger">
|
||||
<Card className="border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||
<Shield className="w-5 h-5" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 className="font-semibold text-red-900 mb-2">
|
||||
Delete Organization
|
||||
</h3>
|
||||
<p className="text-red-700 text-sm mb-4">
|
||||
Once you delete an organization, there is no going back. Please be certain.
|
||||
This will permanently delete all associated websites, data, and team member access.
|
||||
</p>
|
||||
{canManageOrganization ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteOrganization}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Organization
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-red-600">
|
||||
Only organization owners can delete the organization.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Input } from "@/components/ui/forms/Input";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@/components/ui/forms/Form";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Shield,
|
||||
Check,
|
||||
Building2,
|
||||
Users,
|
||||
Star,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Zap,
|
||||
Globe,
|
||||
BarChart3,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2, "Organization name must be at least 2 characters"),
|
||||
});
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Globe,
|
||||
title: "Website Monitoring",
|
||||
description:
|
||||
"Monitor unlimited websites with real-time performance tracking",
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Advanced Analytics",
|
||||
description:
|
||||
"Get detailed insights into performance, SEO, and accessibility",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Team Collaboration",
|
||||
description: "Invite team members and manage access permissions",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Automated Alerts",
|
||||
description: "Receive instant notifications when issues are detected",
|
||||
},
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "forever",
|
||||
description: "Perfect for getting started",
|
||||
features: [
|
||||
"Up to 3 websites",
|
||||
"Basic monitoring",
|
||||
"Email alerts",
|
||||
"7-day data retention",
|
||||
],
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
period: "per month",
|
||||
description: "For growing businesses",
|
||||
features: [
|
||||
"Up to 25 websites",
|
||||
"Advanced monitoring",
|
||||
"Real-time alerts",
|
||||
"90-day data retention",
|
||||
"Team collaboration",
|
||||
],
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
period: "pricing",
|
||||
description: "For large organizations",
|
||||
features: [
|
||||
"Unlimited websites",
|
||||
"Custom integrations",
|
||||
"Priority support",
|
||||
"Unlimited data retention",
|
||||
"SSO & compliance",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function NewOrganizationPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const router = useRouter();
|
||||
const { user, createOrganizationForUser } = useAuth();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: user?.user_metadata?.name
|
||||
? `${user.user_metadata.name}'s Organization`
|
||||
: "My Organization",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!user) {
|
||||
setError("You must be logged in to create an organization");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const orgId = await createOrganizationForUser(user.id, values.name);
|
||||
|
||||
if (!orgId) {
|
||||
throw new Error("Failed to create organization");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setStep(3);
|
||||
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
router.push("/dashboard");
|
||||
}, 2000);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to create organization:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to create organization",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-6xl mx-auto py-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Step 1: Welcome */}
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="text-center space-y-8"
|
||||
>
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
>
|
||||
<Building2 className="w-10 h-10 text-blue-600" />
|
||||
</motion.div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Welcome to CloudLense
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Set up your organization to start monitoring your websites and
|
||||
gain valuable insights into performance, SEO, and
|
||||
accessibility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
{features.map((feature, index) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 + index * 0.1 }}
|
||||
>
|
||||
<Card className="text-left hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleContinue}
|
||||
className="px-8 flex items-center gap-2"
|
||||
>
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Organization Setup */}
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="max-w-2xl mx-auto"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Create Your Organization
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Set up your organization to start monitoring websites
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-blue-600" />
|
||||
Organization Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="My Organization" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This name will be visible to all team members
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Plan Selection */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Choose Your Plan
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.name}
|
||||
className={`relative border rounded-lg p-4 ${
|
||||
plan.current
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge className="absolute -top-2 left-1/2 -translate-x-1/2">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
Popular
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<h4 className="font-semibold">{plan.name}</h4>
|
||||
<div className="mt-2">
|
||||
<span className="text-2xl font-bold">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
/{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{plan.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center text-sm"
|
||||
>
|
||||
<Check className="w-3 h-3 text-green-500 mr-2" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan.current && (
|
||||
<Badge
|
||||
variant="blue"
|
||||
className="w-full justify-center mt-3"
|
||||
>
|
||||
Current Plan
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
You can upgrade or downgrade your plan at any time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep(1)}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Organization"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Success */}
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
>
|
||||
<Check className="w-10 h-10 text-green-600" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Organization Created Successfully!
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Your organization has been set up. You can now start adding
|
||||
websites and inviting team members to collaborate.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm text-blue-600">
|
||||
Redirecting to dashboard...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Input } from "@/components/ui/forms/Input";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/forms/Form";
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Settings,
|
||||
Trash2,
|
||||
Edit,
|
||||
Plus,
|
||||
Calendar,
|
||||
Crown,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
subscription_tier: string;
|
||||
subscription_status: string;
|
||||
created_at: string;
|
||||
member_count: number;
|
||||
website_count: number;
|
||||
user_role: string;
|
||||
}
|
||||
|
||||
const editFormSchema = z.object({
|
||||
name: z.string().min(2, "Organization name must be at least 2 characters"),
|
||||
});
|
||||
|
||||
export default function OrganizationsPage() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingOrg, setEditingOrg] = useState<Organization | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const { user, userDetails } = useAuth();
|
||||
|
||||
const editForm = useForm<z.infer<typeof editFormSchema>>({
|
||||
resolver: zodResolver(editFormSchema),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadOrganizations();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadOrganizations = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Get organizations where user is a member
|
||||
const { data: userOrgs, error: userOrgError } = await supabase
|
||||
.from("users")
|
||||
.select(`
|
||||
organization_id,
|
||||
role,
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
subscription_tier,
|
||||
subscription_status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq("id", user?.id);
|
||||
|
||||
if (userOrgError) throw userOrgError;
|
||||
|
||||
// Get organization stats
|
||||
const orgIds = userOrgs?.map(u => u.organization_id).filter(Boolean) || [];
|
||||
|
||||
const [membersData, websitesData] = await Promise.all([
|
||||
// Get member counts
|
||||
supabase
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds),
|
||||
|
||||
// Get website counts
|
||||
supabase
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds)
|
||||
]);
|
||||
|
||||
const memberCounts = membersData.data?.reduce((acc, member) => {
|
||||
acc[member.organization_id] = (acc[member.organization_id] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const websiteCounts = websitesData.data?.reduce((acc, website) => {
|
||||
acc[website.organization_id] = (acc[website.organization_id] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const orgsWithStats = (userOrgs as Array<any> | undefined)?.map((userOrg: any) => ({
|
||||
id: userOrg.organizations?.id || "",
|
||||
name: userOrg.organizations?.name || "",
|
||||
subscription_tier: userOrg.organizations?.subscription_tier || "free",
|
||||
subscription_status: userOrg.organizations?.subscription_status || "active",
|
||||
created_at: userOrg.organizations?.created_at || "",
|
||||
member_count: memberCounts[String(userOrg.organization_id)] || 0,
|
||||
website_count: websiteCounts[String(userOrg.organization_id)] || 0,
|
||||
user_role: userOrg.role || "member",
|
||||
})).filter((org: any) => org.id) || [];
|
||||
|
||||
setOrganizations(orgsWithStats);
|
||||
} catch (error) {
|
||||
console.error("Error loading organizations:", error);
|
||||
setError("Failed to load organizations");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditOrganization = async (values: z.infer<typeof editFormSchema>) => {
|
||||
if (!editingOrg) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ name: values.name })
|
||||
.eq("id", editingOrg.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Update local state
|
||||
setOrganizations(orgs =>
|
||||
orgs.map(org =>
|
||||
org.id === editingOrg.id
|
||||
? { ...org, name: values.name }
|
||||
: org
|
||||
)
|
||||
);
|
||||
|
||||
setEditingOrg(null);
|
||||
editForm.reset();
|
||||
} catch (error) {
|
||||
console.error("Error updating organization:", error);
|
||||
setError("Failed to update organization");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrganization = async (orgId: string) => {
|
||||
try {
|
||||
// First, check if user is owner
|
||||
const org = organizations.find(o => o.id === orgId);
|
||||
if (org?.user_role !== "owner") {
|
||||
setError("Only organization owners can delete organizations");
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete organization (cascade should handle related records)
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.delete()
|
||||
.eq("id", orgId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Update local state
|
||||
setOrganizations(orgs => orgs.filter(org => org.id !== orgId));
|
||||
setDeleteConfirm(null);
|
||||
|
||||
// If this was the user's current organization, they might need to select a new one
|
||||
if (userDetails?.organization_id === orgId) {
|
||||
router.push("/dashboard/organizations/new");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting organization:", error);
|
||||
setError("Failed to delete organization");
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (org: Organization) => {
|
||||
setEditingOrg(org);
|
||||
editForm.setValue("name", org.name);
|
||||
};
|
||||
|
||||
const getTierColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case "pro": return "text-blue-600 bg-blue-100";
|
||||
case "enterprise": return "text-purple-600 bg-purple-100";
|
||||
default: return "text-gray-600 bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-6xl mx-auto py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Organizations</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your organizations and team settings
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/organizations/new")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Organization
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3 mb-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800">{error}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setError("")}
|
||||
className="ml-auto"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<AnimatePresence>
|
||||
{organizations.map((org) => (
|
||||
<motion.div
|
||||
key={org.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Building2 className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{org.name}</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${getTierColor(org.subscription_tier)}`}>
|
||||
{org.subscription_tier.charAt(0).toUpperCase() + org.subscription_tier.slice(1)}
|
||||
</span>
|
||||
{org.user_role === "owner" && (
|
||||
<Crown className="w-3 h-3 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{org.member_count}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex items-center justify-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
Members
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{org.website_count}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex items-center justify-center gap-1">
|
||||
<Building2 className="w-3 h-3" />
|
||||
Websites
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created Date */}
|
||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Created {new Date(org.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startEdit(org)}
|
||||
className="flex-1"
|
||||
disabled={org.user_role !== "owner"}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/organizations/${org.id}/settings`)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Settings className="w-3 h-3 mr-1" />
|
||||
Settings
|
||||
</Button>
|
||||
{org.user_role === "owner" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(org.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{organizations.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Building2 className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No Organizations Found
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Create your first organization to start monitoring websites
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/organizations/new")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Organization
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Organization Modal */}
|
||||
<AnimatePresence>
|
||||
{editingOrg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white rounded-lg max-w-md w-full"
|
||||
>
|
||||
<Card className="border-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Edit Organization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...editForm}>
|
||||
<form
|
||||
onSubmit={editForm.handleSubmit(handleEditOrganization)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Organization Name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingOrg(null);
|
||||
editForm.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{deleteConfirm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white rounded-lg max-w-md w-full"
|
||||
>
|
||||
<Card className="border-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600">Delete Organization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Are you sure you want to delete this organization? This action cannot be undone and will remove all associated websites and data.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteOrganization(deleteConfirm)}
|
||||
>
|
||||
Delete Organization
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { useDashboardData } from "@/hooks/useDashboardData";
|
||||
import { Card, CardContent } from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
BarChart3,
|
||||
Globe,
|
||||
Zap,
|
||||
Search,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
Shield,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { scanService } from "@/services/scanService";
|
||||
import { DatabaseSetupHelper } from "@/components/ui/DatabaseSetupHelper";
|
||||
import { SupabaseDiagnostic } from "@/components/ui/SupabaseDiagnostic";
|
||||
|
||||
interface DashboardStats {
|
||||
websitesCount: number;
|
||||
activePages: number;
|
||||
totalScans: number;
|
||||
averagePerformance: number;
|
||||
lastScanTime: string;
|
||||
recentScans: any[];
|
||||
websites: any[];
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { userDetails, organizationId, shouldShowLoading } = useDashboardData({ requireOrganization: false });
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationId) {
|
||||
loadDashboardData();
|
||||
} else if (userDetails) {
|
||||
// User exists but no organization, show empty dashboard
|
||||
loadDashboardData();
|
||||
}
|
||||
}, [organizationId, userDetails]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
if (!userDetails?.organization_id) {
|
||||
console.log("No organization_id yet, showing empty dashboard");
|
||||
setStats({
|
||||
websitesCount: 0,
|
||||
activePages: 0,
|
||||
totalScans: 0,
|
||||
averagePerformance: 0,
|
||||
lastScanTime: "Never",
|
||||
recentScans: [],
|
||||
websites: [],
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch websites
|
||||
const { data: websites, error: websitesError } = await supabase
|
||||
.from("websites")
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
is_active,
|
||||
created_at,
|
||||
pages!inner (
|
||||
id,
|
||||
is_active
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq("organization_id", userDetails.organization_id)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (websitesError) throw websitesError;
|
||||
|
||||
// Fetch recent scans
|
||||
let recentScans: any[] = [];
|
||||
try {
|
||||
recentScans = await scanService.getRecentScans(10);
|
||||
} catch (error) {
|
||||
console.log("No scans found yet:", error);
|
||||
recentScans = [];
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const websitesCount = websites?.length || 0;
|
||||
const activePages =
|
||||
websites?.reduce(
|
||||
(sum, website) =>
|
||||
sum + (website.pages?.filter((p: any) => p.is_active).length || 0),
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
const totalScans = recentScans.length;
|
||||
const completedScans = recentScans.filter(
|
||||
(scan) => scan.status === "completed",
|
||||
);
|
||||
const averagePerformance =
|
||||
completedScans.length > 0
|
||||
? Math.round(
|
||||
completedScans.reduce(
|
||||
(sum, scan) => sum + (scan.performance_score || 0),
|
||||
0,
|
||||
) / completedScans.length,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const lastScan = recentScans[0];
|
||||
const lastScanTime = lastScan
|
||||
? new Date(lastScan.created_at).toLocaleString()
|
||||
: "Never";
|
||||
|
||||
setStats({
|
||||
websitesCount,
|
||||
activePages,
|
||||
totalScans,
|
||||
averagePerformance,
|
||||
lastScanTime,
|
||||
recentScans: recentScans.slice(0, 5),
|
||||
websites: websites || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadDashboardData();
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return "text-green-600";
|
||||
if (score >= 70) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const getScoreBadgeColor = (score: number) => {
|
||||
if (score >= 90) return "bg-green-100 text-green-800";
|
||||
if (score >= 70) return "bg-yellow-100 text-yellow-800";
|
||||
return "bg-red-100 text-red-800";
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-600" />;
|
||||
case "running":
|
||||
return <Activity className="w-4 h-4 text-blue-600 animate-pulse" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="w-4 h-4 text-red-600" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading only when absolutely necessary
|
||||
if (shouldShowLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-blue-600" />
|
||||
<span className="text-gray-600">Loading dashboard...</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const quickStats = [
|
||||
{
|
||||
label: "Websites Monitored",
|
||||
value: stats?.websitesCount?.toString() || "0",
|
||||
change: `${stats?.activePages || 0} active pages`,
|
||||
trend: stats?.websitesCount ? "up" : "stable",
|
||||
icon: Globe,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Average Performance",
|
||||
value: stats?.averagePerformance ? `${stats.averagePerformance}%` : "N/A",
|
||||
change:
|
||||
(stats?.averagePerformance ?? 0) >= 90
|
||||
? "Excellent"
|
||||
: (stats?.averagePerformance ?? 0) >= 70
|
||||
? "Good"
|
||||
: "Needs improvement",
|
||||
trend:
|
||||
(stats?.averagePerformance ?? 0) >= 90
|
||||
? "up"
|
||||
: (stats?.averagePerformance ?? 0) >= 70
|
||||
? "stable"
|
||||
: "down",
|
||||
icon: Zap,
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
label: "Total Scans",
|
||||
value: stats?.totalScans?.toString() || "0",
|
||||
change: "All time",
|
||||
trend: "stable",
|
||||
icon: Search,
|
||||
color: "purple",
|
||||
},
|
||||
{
|
||||
label: "Last Scan",
|
||||
value: stats?.lastScanTime === "Never" ? "Never" : "Recent",
|
||||
change: stats?.lastScanTime || "No scans yet",
|
||||
trend: "stable",
|
||||
icon: Clock,
|
||||
color: "gray",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
{/* Database Setup Helper */}
|
||||
<DatabaseSetupHelper />
|
||||
|
||||
{/* Supabase Diagnostic */}
|
||||
<SupabaseDiagnostic />
|
||||
|
||||
{/* Welcome Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-3xl font-bold text-gray-900"
|
||||
>
|
||||
Welcome back, {userDetails?.name?.split(" ")[0] || "User"}!
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-gray-600 mt-2"
|
||||
>
|
||||
Monitor your website performance and SEO in real-time
|
||||
</motion.p>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/websites/new")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Website
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{quickStats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="hover:shadow-lg transition-all duration-200 hover:scale-105">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{stat.value}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
{stat.trend === "up" && (
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
)}
|
||||
{stat.trend === "down" && (
|
||||
<TrendingDown className="w-3 h-3 text-red-500 mr-1" />
|
||||
)}
|
||||
<p
|
||||
className={`text-xs ${
|
||||
stat.trend === "up"
|
||||
? "text-green-600"
|
||||
: stat.trend === "down"
|
||||
? "text-red-600"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{stat.change}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`p-3 rounded-lg bg-${stat.color}-100 flex-shrink-0`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 text-${stat.color}-600`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Recent Scans */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Recent Scans
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/dashboard/scans")}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
View All
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(stats?.recentScans?.length ?? 0) > 0 ? (
|
||||
stats?.recentScans?.map((scan, index) => (
|
||||
<motion.div
|
||||
key={scan.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 + index * 0.1 }}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(scan.status)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-48">
|
||||
{scan.pages?.title ||
|
||||
scan.pages?.url ||
|
||||
"Unknown Page"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(scan.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{scan.performance_score && (
|
||||
<Badge
|
||||
className={getScoreBadgeColor(
|
||||
scan.performance_score,
|
||||
)}
|
||||
>
|
||||
{scan.performance_score}%
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="gray" className="text-xs">
|
||||
{scan.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>No scans yet</p>
|
||||
<p className="text-sm">
|
||||
Start monitoring your websites to see scan results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Websites Overview */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-green-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Your Websites
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/dashboard/websites")}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Manage All
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(stats?.websites?.length ?? 0) > 0 ? (
|
||||
stats?.websites?.slice(0, 5).map((website, index) => (
|
||||
<motion.div
|
||||
key={website.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 + index * 0.1 }}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/websites/${website.id}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Globe className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{website.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1">
|
||||
{website.base_url}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="green" className="text-xs">
|
||||
{website.pages?.filter((p: any) => p.is_active)
|
||||
.length || 0}{" "}
|
||||
pages
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
website.is_active
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}
|
||||
>
|
||||
{website.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Globe className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>No websites added yet</p>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/websites/new")}
|
||||
className="mt-4 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Your First Website
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Get started with monitoring your websites
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/dashboard/websites")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
View Websites
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/dashboard/scans")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
View Reports
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/websites/new")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Website
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Zap,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
|
||||
|
||||
interface PerformanceMetric {
|
||||
id: string;
|
||||
website_name: string;
|
||||
website_url: string;
|
||||
lighthouse_score: number;
|
||||
performance_score: number;
|
||||
accessibility_score: number;
|
||||
best_practices_score: number;
|
||||
seo_score: number;
|
||||
first_contentful_paint: number;
|
||||
largest_contentful_paint: number;
|
||||
cumulative_layout_shift: number;
|
||||
total_blocking_time: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PerformanceSummary {
|
||||
totalWebsites: number;
|
||||
averageScore: number;
|
||||
goodPerformance: number;
|
||||
needsImprovement: number;
|
||||
poor: number;
|
||||
}
|
||||
|
||||
export default function PerformancePage() {
|
||||
const { userDetails } = useAuth();
|
||||
const [metrics, setMetrics] = useState<PerformanceMetric[]>([]);
|
||||
const [summary, setSummary] = useState<PerformanceSummary>({
|
||||
totalWebsites: 0,
|
||||
averageScore: 0,
|
||||
goodPerformance: 0,
|
||||
needsImprovement: 0,
|
||||
poor: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d");
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.organization_id) {
|
||||
loadPerformanceData();
|
||||
}
|
||||
}, [userDetails, timeRange]);
|
||||
|
||||
const loadPerformanceData = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Calculate date range
|
||||
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90;
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
|
||||
// Fetch latest performance data for each website
|
||||
const { data: scanData, error } = await supabase
|
||||
.from("scans")
|
||||
.select(`
|
||||
id,
|
||||
lighthouse_score,
|
||||
created_at,
|
||||
scan_results!inner (
|
||||
category,
|
||||
score,
|
||||
metrics
|
||||
),
|
||||
pages!inner (
|
||||
websites!inner (
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
organization_id
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("pages.websites.organization_id", userDetails.organization_id)
|
||||
.eq("status", "completed")
|
||||
.gte("created_at", startDate.toISOString())
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
const errorInfo = extractSupabaseErrorInfo(error);
|
||||
logError("Error loading performance data", error, {
|
||||
organizationId: userDetails.organization_id,
|
||||
timeRange,
|
||||
startDate: startDate.toISOString(),
|
||||
supabaseError: errorInfo
|
||||
});
|
||||
|
||||
// If tables don't exist, set empty metrics
|
||||
if (errorInfo.message?.includes("does not exist") || errorInfo.details?.includes("does not exist")) {
|
||||
setMetrics([]);
|
||||
setSummary({
|
||||
totalWebsites: 0,
|
||||
averageScore: 0,
|
||||
goodPerformance: 0,
|
||||
needsImprovement: 0,
|
||||
poor: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Process the data to get latest metrics per website
|
||||
const websiteMetrics = new Map<string, PerformanceMetric>();
|
||||
|
||||
scanData?.forEach((scan: any) => {
|
||||
const website = scan.pages.websites;
|
||||
if (!websiteMetrics.has(website.id)) {
|
||||
const results = scan.scan_results || [];
|
||||
|
||||
websiteMetrics.set(website.id, {
|
||||
id: scan.id,
|
||||
website_name: website.name,
|
||||
website_url: website.base_url,
|
||||
lighthouse_score: scan.lighthouse_score || 0,
|
||||
performance_score: results.find((r: any) => r.category === "performance")?.score || 0,
|
||||
accessibility_score: results.find((r: any) => r.category === "accessibility")?.score || 0,
|
||||
best_practices_score: results.find((r: any) => r.category === "best-practices")?.score || 0,
|
||||
seo_score: results.find((r: any) => r.category === "seo")?.score || 0,
|
||||
first_contentful_paint: results.find((r: any) => r.category === "performance")?.metrics?.first_contentful_paint || 0,
|
||||
largest_contentful_paint: results.find((r: any) => r.category === "performance")?.metrics?.largest_contentful_paint || 0,
|
||||
cumulative_layout_shift: results.find((r: any) => r.category === "performance")?.metrics?.cumulative_layout_shift || 0,
|
||||
total_blocking_time: results.find((r: any) => r.category === "performance")?.metrics?.total_blocking_time || 0,
|
||||
created_at: scan.created_at,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metricsArray = Array.from(websiteMetrics.values());
|
||||
setMetrics(metricsArray);
|
||||
|
||||
// Calculate summary
|
||||
if (metricsArray.length > 0) {
|
||||
const avgScore = Math.round(
|
||||
metricsArray.reduce((sum, m) => sum + m.lighthouse_score, 0) / metricsArray.length
|
||||
);
|
||||
const good = metricsArray.filter(m => m.lighthouse_score >= 90).length;
|
||||
const needsImprovement = metricsArray.filter(m => m.lighthouse_score >= 50 && m.lighthouse_score < 90).length;
|
||||
const poor = metricsArray.filter(m => m.lighthouse_score < 50).length;
|
||||
|
||||
setSummary({
|
||||
totalWebsites: metricsArray.length,
|
||||
averageScore: avgScore,
|
||||
goodPerformance: good,
|
||||
needsImprovement,
|
||||
poor,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorInfo = extractSupabaseErrorInfo(error);
|
||||
logError("Error loading performance data", error, {
|
||||
organizationId: userDetails.organization_id,
|
||||
timeRange,
|
||||
function: "loadPerformanceData",
|
||||
supabaseError: errorInfo
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return "text-green-600 bg-green-100";
|
||||
if (score >= 50) return "text-yellow-600 bg-yellow-100";
|
||||
return "text-red-600 bg-red-100";
|
||||
};
|
||||
|
||||
const getScoreIcon = (score: number) => {
|
||||
if (score >= 90) return <CheckCircle className="w-4 h-4" />;
|
||||
if (score >= 50) return <AlertTriangle className="w-4 h-4" />;
|
||||
return <TrendingDown className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Zap className="w-6 h-6" />
|
||||
Performance Overview
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Monitor and analyze your websites' performance metrics
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as "7d" | "30d" | "90d")}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Websites</p>
|
||||
<p className="text-2xl font-bold">{summary.totalWebsites}</p>
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Average Score</p>
|
||||
<p className="text-2xl font-bold">{summary.averageScore}</p>
|
||||
</div>
|
||||
<Target className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Good Performance</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.goodPerformance}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Needs Improvement</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.needsImprovement}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Website Performance</h2>
|
||||
|
||||
{metrics.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<motion.div
|
||||
key={metric.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{metric.website_name}</h3>
|
||||
<p className="text-sm text-gray-500">{metric.website_url}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Last scan: {new Date(metric.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`flex items-center gap-1 ${getScoreColor(metric.lighthouse_score)}`}>
|
||||
{getScoreIcon(metric.lighthouse_score)}
|
||||
{metric.lighthouse_score}/100
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-semibold ${getScoreColor(metric.performance_score).split(' ')[0]}`}>
|
||||
{metric.performance_score}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Performance</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-semibold ${getScoreColor(metric.accessibility_score).split(' ')[0]}`}>
|
||||
{metric.accessibility_score}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Accessibility</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-semibold ${getScoreColor(metric.best_practices_score).split(' ')[0]}`}>
|
||||
{metric.best_practices_score}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Best Practices</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-semibold ${getScoreColor(metric.seo_score).split(' ')[0]}`}>
|
||||
{metric.seo_score}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">SEO</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(metric.first_contentful_paint > 0 || metric.largest_contentful_paint > 0) && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{metric.first_contentful_paint > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{(metric.first_contentful_paint / 1000).toFixed(1)}s
|
||||
</div>
|
||||
<div className="text-gray-500">FCP</div>
|
||||
</div>
|
||||
)}
|
||||
{metric.largest_contentful_paint > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{(metric.largest_contentful_paint / 1000).toFixed(1)}s
|
||||
</div>
|
||||
<div className="text-gray-500">LCP</div>
|
||||
</div>
|
||||
)}
|
||||
{metric.cumulative_layout_shift > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{metric.cumulative_layout_shift.toFixed(3)}
|
||||
</div>
|
||||
<div className="text-gray-500">CLS</div>
|
||||
</div>
|
||||
)}
|
||||
{metric.total_blocking_time > 0 && (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{metric.total_blocking_time}ms
|
||||
</div>
|
||||
<div className="text-gray-500">TBT</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Zap className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No performance data available
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Run scans on your websites to see performance metrics here
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Target,
|
||||
FileText,
|
||||
Link,
|
||||
Image,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
|
||||
|
||||
interface SEOMetric {
|
||||
id: string;
|
||||
website_name: string;
|
||||
website_url: string;
|
||||
seo_score: number;
|
||||
title_tag: boolean;
|
||||
meta_description: boolean;
|
||||
h1_tag: boolean;
|
||||
image_alt_text: number;
|
||||
internal_links: number;
|
||||
external_links: number;
|
||||
page_speed_score: number;
|
||||
mobile_friendly: boolean;
|
||||
ssl_certificate: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SEOSummary {
|
||||
totalPages: number;
|
||||
averageSEOScore: number;
|
||||
goodSEO: number;
|
||||
needsImprovement: number;
|
||||
poor: number;
|
||||
}
|
||||
|
||||
export default function SEOPage() {
|
||||
const { userDetails } = useAuth();
|
||||
const [metrics, setMetrics] = useState<SEOMetric[]>([]);
|
||||
const [summary, setSummary] = useState<SEOSummary>({
|
||||
totalPages: 0,
|
||||
averageSEOScore: 0,
|
||||
goodSEO: 0,
|
||||
needsImprovement: 0,
|
||||
poor: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d");
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.organization_id) {
|
||||
loadSEOData();
|
||||
}
|
||||
}, [userDetails, timeRange]);
|
||||
|
||||
const loadSEOData = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90;
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
|
||||
// Fetch latest SEO data
|
||||
const { data: scanData, error } = await supabase
|
||||
.from("scans")
|
||||
.select(`
|
||||
id,
|
||||
lighthouse_score,
|
||||
created_at,
|
||||
scan_results!inner (
|
||||
category,
|
||||
score,
|
||||
details
|
||||
),
|
||||
pages!inner (
|
||||
id,
|
||||
url,
|
||||
websites!inner (
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
organization_id
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("pages.websites.organization_id", userDetails.organization_id)
|
||||
.eq("status", "completed")
|
||||
.gte("created_at", startDate.toISOString())
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Process SEO data
|
||||
const pageMetrics = new Map<string, SEOMetric>();
|
||||
|
||||
scanData?.forEach((scan: any) => {
|
||||
const page = scan.pages;
|
||||
const website = page.websites;
|
||||
const seoResult = scan.scan_results?.find((r: any) => r.category === "seo");
|
||||
const performanceResult = scan.scan_results?.find((r: any) => r.category === "performance");
|
||||
|
||||
if (!pageMetrics.has(page.id) && seoResult) {
|
||||
const details = seoResult.details || {};
|
||||
|
||||
pageMetrics.set(page.id, {
|
||||
id: scan.id,
|
||||
website_name: website.name,
|
||||
website_url: page.url || website.base_url,
|
||||
seo_score: seoResult.score || 0,
|
||||
title_tag: details.has_title_tag || false,
|
||||
meta_description: details.has_meta_description || false,
|
||||
h1_tag: details.has_h1_tag || false,
|
||||
image_alt_text: details.images_with_alt || 0,
|
||||
internal_links: details.internal_links || 0,
|
||||
external_links: details.external_links || 0,
|
||||
page_speed_score: performanceResult?.score || 0,
|
||||
mobile_friendly: details.mobile_friendly || false,
|
||||
ssl_certificate: website.base_url?.startsWith("https://") || false,
|
||||
created_at: scan.created_at,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metricsArray = Array.from(pageMetrics.values());
|
||||
setMetrics(metricsArray);
|
||||
|
||||
// Calculate summary
|
||||
if (metricsArray.length > 0) {
|
||||
const avgScore = Math.round(
|
||||
metricsArray.reduce((sum, m) => sum + m.seo_score, 0) / metricsArray.length
|
||||
);
|
||||
const good = metricsArray.filter(m => m.seo_score >= 90).length;
|
||||
const needsImprovement = metricsArray.filter(m => m.seo_score >= 50 && m.seo_score < 90).length;
|
||||
const poor = metricsArray.filter(m => m.seo_score < 50).length;
|
||||
|
||||
setSummary({
|
||||
totalPages: metricsArray.length,
|
||||
averageSEOScore: avgScore,
|
||||
goodSEO: good,
|
||||
needsImprovement,
|
||||
poor,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorInfo = extractSupabaseErrorInfo(error);
|
||||
logError("Error loading SEO data", error, {
|
||||
organizationId: userDetails.organization_id,
|
||||
timeRange,
|
||||
function: "loadSEOData",
|
||||
supabaseError: errorInfo
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return "text-green-600 bg-green-100";
|
||||
if (score >= 50) return "text-yellow-600 bg-yellow-100";
|
||||
return "text-red-600 bg-red-100";
|
||||
};
|
||||
|
||||
const getScoreIcon = (score: number) => {
|
||||
if (score >= 90) return <CheckCircle className="w-4 h-4" />;
|
||||
if (score >= 50) return <AlertTriangle className="w-4 h-4" />;
|
||||
return <TrendingDown className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Search className="w-6 h-6" />
|
||||
SEO Analysis
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Monitor and optimize your websites' search engine optimization
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as "7d" | "30d" | "90d")}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Pages</p>
|
||||
<p className="text-2xl font-bold">{summary.totalPages}</p>
|
||||
</div>
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Average SEO Score</p>
|
||||
<p className="text-2xl font-bold">{summary.averageSEOScore}</p>
|
||||
</div>
|
||||
<Target className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Good SEO</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.goodSEO}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Needs Improvement</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.needsImprovement}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SEO Metrics */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Page SEO Analysis</h2>
|
||||
|
||||
{metrics.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<motion.div
|
||||
key={metric.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{metric.website_name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{metric.website_url}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Last scan: {new Date(metric.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`flex items-center gap-1 ${getScoreColor(metric.seo_score)}`}>
|
||||
{getScoreIcon(metric.seo_score)}
|
||||
{metric.seo_score}/100
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* SEO Checklist */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.title_tag ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">Title Tag</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.meta_description ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">Meta Description</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.h1_tag ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">H1 Tag</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.ssl_certificate ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">SSL Certificate</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.mobile_friendly ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">Mobile Friendly</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm">{metric.image_alt_text} Images with Alt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links and Performance */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="w-4 h-4 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.internal_links}</div>
|
||||
<div className="text-gray-500">Internal Links</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="w-4 h-4 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.external_links}</div>
|
||||
<div className="text-gray-500">External Links</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.page_speed_score}</div>
|
||||
<div className="text-gray-500">Speed Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-indigo-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.seo_score}</div>
|
||||
<div className="text-gray-500">SEO Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Search className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No SEO data available
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Run scans on your websites to see SEO analysis here
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
Bell,
|
||||
Shield,
|
||||
CreditCard,
|
||||
Key,
|
||||
Mail,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Database,
|
||||
Zap,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
interface UserSettings {
|
||||
email_notifications: boolean;
|
||||
sms_notifications: boolean;
|
||||
browser_notifications: boolean;
|
||||
weekly_report: boolean;
|
||||
timezone: string;
|
||||
date_format: string;
|
||||
}
|
||||
|
||||
interface OrganizationSettings {
|
||||
name: string;
|
||||
subscription_tier: string;
|
||||
subscription_status: string;
|
||||
max_websites: number;
|
||||
max_scans_per_month: number;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, userDetails } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"profile" | "notifications" | "organization" | "billing" | "api">("profile");
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>({
|
||||
email_notifications: true,
|
||||
sms_notifications: false,
|
||||
browser_notifications: true,
|
||||
weekly_report: true,
|
||||
timezone: "UTC",
|
||||
date_format: "MM/DD/YYYY",
|
||||
});
|
||||
const [orgSettings, setOrgSettings] = useState<OrganizationSettings | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [userDetails]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load organization settings
|
||||
const { data: orgData, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("*")
|
||||
.eq("id", userDetails.organization_id)
|
||||
.single();
|
||||
|
||||
if (orgError) throw orgError;
|
||||
|
||||
if (orgData) {
|
||||
setOrgSettings({
|
||||
name: orgData.name,
|
||||
subscription_tier: orgData.subscription_tier,
|
||||
subscription_status: orgData.subscription_status,
|
||||
max_websites: orgData.max_websites || 10,
|
||||
max_scans_per_month: orgData.max_scans_per_month || 1000,
|
||||
api_key: orgData.api_key || "sk-" + Math.random().toString(36).substring(2, 15),
|
||||
});
|
||||
}
|
||||
|
||||
// Load user notification preferences (if they exist)
|
||||
const { data: notificationData } = await supabase
|
||||
.from("user_notification_preferences")
|
||||
.select("*")
|
||||
.eq("user_id", user?.id)
|
||||
.single();
|
||||
|
||||
if (notificationData) {
|
||||
setUserSettings({
|
||||
email_notifications: notificationData.email_notifications,
|
||||
sms_notifications: notificationData.sms_notifications,
|
||||
browser_notifications: notificationData.browser_notifications,
|
||||
weekly_report: notificationData.weekly_report,
|
||||
timezone: notificationData.timezone || "UTC",
|
||||
date_format: notificationData.date_format || "MM/DD/YYYY",
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserSettings = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
const { error } = await supabase
|
||||
.from("user_notification_preferences")
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
...userSettings,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setSuccess("Settings saved successfully");
|
||||
setTimeout(() => setSuccess(""), 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving settings:", error);
|
||||
setError("Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveOrgSettings = async () => {
|
||||
if (!userDetails?.organization_id || !orgSettings) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({
|
||||
name: orgSettings.name,
|
||||
})
|
||||
.eq("id", userDetails.organization_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setSuccess("Organization settings saved successfully");
|
||||
setTimeout(() => setSuccess(""), 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving organization settings:", error);
|
||||
setError("Failed to save organization settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateNewApiKey = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const newApiKey = "sk-" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ api_key: newApiKey })
|
||||
.eq("id", userDetails.organization_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setOrgSettings(prev => prev ? { ...prev, api_key: newApiKey } : null);
|
||||
setSuccess("New API key generated successfully");
|
||||
setTimeout(() => setSuccess(""), 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating API key:", error);
|
||||
setError("Failed to generate new API key");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "organization", label: "Organization", icon: Globe },
|
||||
{ id: "billing", label: "Billing", icon: CreditCard },
|
||||
{ id: "api", label: "API", icon: Key },
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Settings className="w-6 h-6" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage your account and organization preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{success && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-green-500" />
|
||||
<span className="text-green-800">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<X className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab.id
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{activeTab === "profile" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
Profile Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userDetails?.name || ""}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email || ""}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
{userDetails?.role?.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Member Since
|
||||
</label>
|
||||
<span className="text-gray-600">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "notifications" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5" />
|
||||
Notification Preferences
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Email Notifications</p>
|
||||
<p className="text-sm text-gray-500">Receive alerts and updates via email</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userSettings.email_notifications}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, email_notifications: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Smartphone className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">SMS Notifications</p>
|
||||
<p className="text-sm text-gray-500">Receive urgent alerts via SMS</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userSettings.sms_notifications}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, sms_notifications: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Browser Notifications</p>
|
||||
<p className="text-sm text-gray-500">Show desktop notifications in your browser</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userSettings.browser_notifications}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, browser_notifications: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Weekly Reports</p>
|
||||
<p className="text-sm text-gray-500">Receive weekly performance summaries</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userSettings.weekly_report}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, weekly_report: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
onClick={saveUserSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4" />
|
||||
)}
|
||||
Save Preferences
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "organization" && orgSettings && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
Organization Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgSettings.name}
|
||||
onChange={(e) => setOrgSettings({ ...orgSettings, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subscription Plan
|
||||
</label>
|
||||
<Badge className="bg-purple-100 text-purple-800">
|
||||
{orgSettings.subscription_tier.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<Badge className={orgSettings.subscription_status === "active" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}>
|
||||
{orgSettings.subscription_status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max Websites
|
||||
</label>
|
||||
<span className="text-gray-600">{orgSettings.max_websites}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max Scans per Month
|
||||
</label>
|
||||
<span className="text-gray-600">{orgSettings.max_scans_per_month.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
onClick={saveOrgSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "billing" && (
|
||||
<BillingTab organizationId={userDetails?.organization_id} />
|
||||
)}
|
||||
|
||||
{activeTab === "api" && orgSettings && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
API Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={orgSettings.api_key}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
readOnly
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={generateNewApiKey}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Regenerate"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Use this API key to authenticate requests to our API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">API Endpoints</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<code className="text-blue-600">GET /api/websites</code>
|
||||
<span className="text-gray-500">List websites</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="text-blue-600">POST /api/websites/{"{id}"}/scan</code>
|
||||
<span className="text-gray-500">Trigger scan</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="text-blue-600">GET /api/scans/{"{id}"}</code>
|
||||
<span className="text-gray-500">Get scan results</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingTab({ organizationId }: { organizationId?: string }) {
|
||||
const [billingData, setBillingData] = useState<{
|
||||
organization: { tier: string; status: string };
|
||||
plan: { name: string; price: string; websites: number; scansPerMonth: number; teamMembers: number };
|
||||
usage: {
|
||||
websites: { used: number; limit: number; percentage: number };
|
||||
scansThisMonth: { used: number; limit: number; percentage: number };
|
||||
teamMembers: { used: number; limit: number; percentage: number };
|
||||
totalScans: number;
|
||||
activeAlerts: number;
|
||||
};
|
||||
features: Record<string, boolean>;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchBilling = useCallback(async () => {
|
||||
if (!organizationId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/billing/usage?organizationId=${organizationId}`);
|
||||
if (res.ok) setBillingData(await res.json());
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch billing:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [organizationId]);
|
||||
|
||||
useEffect(() => { fetchBilling(); }, [fetchBilling]);
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-gray-500">Loading billing data...</div>;
|
||||
if (!billingData) return <div className="text-center py-12 text-gray-500">No billing data available</div>;
|
||||
|
||||
const { plan, usage, features } = billingData;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Current Plan: {plan.name}</span>
|
||||
<span className="text-2xl font-bold text-blue-600">{plan.price}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<UsageBar label="Websites" used={usage.websites.used} limit={usage.websites.limit} />
|
||||
<UsageBar label="Scans (this month)" used={usage.scansThisMonth.used} limit={usage.scansThisMonth.limit} />
|
||||
<UsageBar label="Team Members" used={usage.teamMembers.used} limit={usage.teamMembers.limit} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Plan Features</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(features).map(([key, enabled]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className={`text-lg ${enabled ? "text-green-500" : "text-gray-300"}`}>
|
||||
{enabled ? "✓" : "✗"}
|
||||
</span>
|
||||
<span className={`text-sm ${enabled ? "text-gray-900" : "text-gray-400"}`}>
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage Stats */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Usage Summary</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-3xl font-bold">{usage.totalScans.toLocaleString()}</p>
|
||||
<p className="text-sm text-gray-500">Total Scans (all time)</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-3xl font-bold">{usage.activeAlerts}</p>
|
||||
<p className="text-sm text-gray-500">Active Alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upgrade prompt */}
|
||||
{billingData.organization.tier === "free" && (
|
||||
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg p-6 text-white">
|
||||
<h3 className="text-lg font-bold mb-2">Upgrade to unlock more features</h3>
|
||||
<p className="text-blue-100 mb-4">
|
||||
Get scheduled scans, alert notifications, more websites, and priority support.
|
||||
</p>
|
||||
<p className="text-sm text-blue-200">
|
||||
Contact your admin to upgrade your organization's plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBar({ label, used, limit }: { label: string; used: number; limit: number }) {
|
||||
const isUnlimited = limit === -1;
|
||||
const percentage = isUnlimited ? 0 : Math.min(Math.round((used / limit) * 100), 100);
|
||||
const barColor = percentage >= 90 ? "bg-red-500" : percentage >= 70 ? "bg-yellow-500" : "bg-blue-500";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="font-medium">{used} / {isUnlimited ? "∞" : limit}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${isUnlimited ? 0 : percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Mail,
|
||||
Settings,
|
||||
Trash2,
|
||||
Crown,
|
||||
Shield,
|
||||
User,
|
||||
MoreVertical,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
status: "active" | "pending";
|
||||
created_at: string;
|
||||
last_login_at?: string;
|
||||
}
|
||||
|
||||
export default function TeamPage() {
|
||||
const router = useRouter();
|
||||
const { userDetails, user } = useAuth();
|
||||
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<"admin" | "member">("member");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.organization_id) {
|
||||
loadTeamMembers();
|
||||
}
|
||||
}, [userDetails]);
|
||||
|
||||
const loadTeamMembers = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("users")
|
||||
.select("*")
|
||||
.eq("organization_id", userDetails.organization_id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error loading team members:", error);
|
||||
// If it's a missing table error, set empty array
|
||||
if (error.message?.includes("does not exist")) {
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
setMembers(data || []);
|
||||
} catch (error) {
|
||||
console.error("Error loading team members:", error);
|
||||
setError("Failed to load team members");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim() || !userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setInviting(true);
|
||||
setError("");
|
||||
|
||||
// Check if user already exists
|
||||
const { data: existingUser } = await supabase
|
||||
.from("users")
|
||||
.select("id")
|
||||
.eq("email", inviteEmail.toLowerCase())
|
||||
.single();
|
||||
|
||||
if (existingUser) {
|
||||
setError("User is already a member of an organization");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send invitation (in a real app, you'd send an email)
|
||||
// For now, we'll create a pending user record
|
||||
const { error: inviteError } = await supabase
|
||||
.from("team_invitations")
|
||||
.insert([
|
||||
{
|
||||
email: inviteEmail.toLowerCase(),
|
||||
role: inviteRole,
|
||||
organization_id: userDetails.organization_id,
|
||||
invited_by: user?.id,
|
||||
status: "pending",
|
||||
},
|
||||
]);
|
||||
|
||||
if (inviteError) throw inviteError;
|
||||
|
||||
setSuccess(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteEmail("");
|
||||
setInviteRole("member");
|
||||
await loadTeamMembers();
|
||||
} catch (error) {
|
||||
console.error("Error inviting member:", error);
|
||||
setError("Failed to send invitation");
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: string) => {
|
||||
if (!confirm("Are you sure you want to remove this team member?")) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("users")
|
||||
.delete()
|
||||
.eq("id", memberId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setSuccess("Team member removed successfully");
|
||||
await loadTeamMembers();
|
||||
} catch (error) {
|
||||
console.error("Error removing member:", error);
|
||||
setError("Failed to remove team member");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (memberId: string, newRole: string) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("users")
|
||||
.update({ role: newRole })
|
||||
.eq("id", memberId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setSuccess("Member role updated successfully");
|
||||
await loadTeamMembers();
|
||||
} catch (error) {
|
||||
console.error("Error updating role:", error);
|
||||
setError("Failed to update member role");
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return <Crown className="w-4 h-4 text-yellow-500" />;
|
||||
case "admin":
|
||||
return <Shield className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return <User className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "admin":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const canManageMembers = userDetails?.role === "owner" || userDetails?.role === "admin";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence>
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3"
|
||||
>
|
||||
<Check className="w-5 h-5 text-green-500" />
|
||||
<span className="text-green-800">{success}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSuccess("")}
|
||||
className="ml-auto"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3"
|
||||
>
|
||||
<X className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800">{error}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setError("")}
|
||||
className="ml-auto"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-6 h-6" />
|
||||
Team Members ({members.length})
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage your organization's team members and permissions
|
||||
</p>
|
||||
</div>
|
||||
{canManageMembers && (
|
||||
<Button
|
||||
onClick={() => document.getElementById("invite-form")?.scrollIntoView()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Form */}
|
||||
{canManageMembers && (
|
||||
<Card id="invite-form">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
Invite New Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInviteMember} className="flex gap-4">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter email address"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as "admin" | "member")}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<Button type="submit" disabled={inviting}>
|
||||
{inviting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Send Invite"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Members List */}
|
||||
<div className="grid gap-4">
|
||||
{members.map((member) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="group"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{member.name}</h3>
|
||||
<p className="text-sm text-gray-500">{member.email}</p>
|
||||
{member.last_login_at && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Last login: {new Date(member.last_login_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={`flex items-center gap-1 ${getRoleBadgeColor(member.role)}`}>
|
||||
{getRoleIcon(member.role)}
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</Badge>
|
||||
|
||||
{canManageMembers && member.id !== user?.id && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{member.role !== "owner" && (
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) => handleUpdateRole(member.id, e.target.value)}
|
||||
className="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
)}
|
||||
{member.role !== "owner" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMember(member.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{members.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Users className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No team members yet
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Start building your team by inviting members to your organization
|
||||
</p>
|
||||
{canManageMembers && (
|
||||
<Button
|
||||
onClick={() => document.getElementById("invite-form")?.scrollIntoView()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Invite Your First Member
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { WebsiteSettings } from "@/components/dashboard/WebsiteSettings";
|
||||
import { CrawlerControl } from "@/components/dashboard/CrawlerControl";
|
||||
import { CrawlDebugger } from "@/components/debug/CrawlDebugger";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import { Card, CardContent } from "@/components/ui/layout/Card";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Trash2,
|
||||
Globe,
|
||||
Calendar,
|
||||
Activity,
|
||||
FileText,
|
||||
Search,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Play,
|
||||
Bug,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { websiteService } from "@/services/websiteService";
|
||||
import { ScanScheduleManager } from '@/components/dashboard/ScanScheduleManager';
|
||||
|
||||
interface WebsiteData {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
organization_id: string;
|
||||
stats: {
|
||||
pagesCount: number;
|
||||
scansCount: number;
|
||||
latestScan: {
|
||||
id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Custom hook to handle async params
|
||||
function useAsyncParams<T>(params: Promise<T> | T): T | null {
|
||||
const [resolvedParams, setResolvedParams] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
try {
|
||||
const resolved = await Promise.resolve(params);
|
||||
setResolvedParams(resolved);
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve params:", error);
|
||||
}
|
||||
};
|
||||
resolveParams();
|
||||
}, [params]);
|
||||
|
||||
return resolvedParams;
|
||||
}
|
||||
|
||||
export default function WebsiteDetailsPage(props: any) {
|
||||
// Handle async params properly for Next.js 15+
|
||||
const [websiteId, setWebsiteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
try {
|
||||
const params = await Promise.resolve(props?.params);
|
||||
setWebsiteId(params?.id || null);
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve params:", error);
|
||||
}
|
||||
};
|
||||
resolveParams();
|
||||
}, [props?.params]);
|
||||
const [website, setWebsite] = useState<WebsiteData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("overview");
|
||||
const router = useRouter();
|
||||
|
||||
const loadWebsiteData = useCallback(async () => {
|
||||
if (!websiteId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await websiteService.getWebsite(websiteId);
|
||||
setWebsite(data as WebsiteData);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load website data",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [websiteId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWebsiteData();
|
||||
}, [loadWebsiteData]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!websiteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("websites")
|
||||
.delete()
|
||||
.eq("id", websiteId);
|
||||
if (error) {
|
||||
alert("Failed to delete website: " + error.message);
|
||||
} else {
|
||||
router.push("/dashboard/websites");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
alert(
|
||||
"Failed to delete website: " +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
completed: { color: "green", icon: CheckCircle },
|
||||
running: { color: "blue", icon: Clock },
|
||||
failed: { color: "red", icon: AlertCircle },
|
||||
pending: { color: "yellow", icon: Clock },
|
||||
};
|
||||
|
||||
const config =
|
||||
statusConfig[status as keyof typeof statusConfig] || statusConfig.pending;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.color as "green" | "blue" | "red" | "yellow"}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading || !websiteId) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p>{!websiteId ? "Loading..." : "Loading website details..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !website) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600">{error || "Website not found"}</p>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/websites")}
|
||||
className="mt-4"
|
||||
>
|
||||
Back to Websites
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(website.base_url)}`}
|
||||
alt="Website favicon"
|
||||
className="w-12 h-12 rounded-lg border shadow-sm"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{website.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
<a
|
||||
href={website.base_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
{website.base_url}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={website.is_active ? "green" : "gray"}>
|
||||
{website.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: "overview", label: "Overview", icon: Activity },
|
||||
{ id: "crawler", label: "Crawler Control", icon: Play },
|
||||
{ id: "debug", label: "Debug", icon: Bug },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSection(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeSection === tab.id
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Sections */}
|
||||
{activeSection === "overview" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Statistics Cards */}
|
||||
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Total Pages
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{website.stats.pagesCount}
|
||||
</p>
|
||||
</div>
|
||||
<FileText className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Total Scans
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{website.stats.scansCount}
|
||||
</p>
|
||||
</div>
|
||||
<Search className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Status
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{website.stats.latestScan ? (
|
||||
getStatusBadge(website.stats.latestScan.status)
|
||||
) : (
|
||||
<Badge variant="gray">No scans</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Website Information */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
Website Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Created</p>
|
||||
<p className="font-medium flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(website.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{website.stats.latestScan && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Scan</p>
|
||||
<p className="font-medium flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDate(website.stats.latestScan.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Website ID</p>
|
||||
<p className="font-mono text-sm bg-gray-100 p-2 rounded">
|
||||
{website.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
|
||||
{website.stats.latestScan ? (
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Latest Scan Completed</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Scan ID: {website.stats.latestScan.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{getStatusBadge(website.stats.latestScan.status)}
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{formatDate(website.stats.latestScan.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">No recent activity</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "crawler" && websiteId && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">
|
||||
Crawler Control Panel
|
||||
</h2>
|
||||
<CrawlerControl websiteId={websiteId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "debug" && websiteId && (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<CrawlDebugger websiteId={websiteId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "settings" && websiteId && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<WebsiteSettings websiteId={websiteId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "danger" && websiteId && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="border-red-200">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="text-2xl font-bold text-red-700 mb-6">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<div className="bg-red-50 border border-red-200 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-red-800 mb-3">
|
||||
Delete Website
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
Once you delete a website, there is no going back. This will
|
||||
permanently delete the website and all associated data
|
||||
including scans, pages, and analytics.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Website
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Delete Website</h3>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
To{" "}
|
||||
<span className="font-bold text-red-600">
|
||||
permanently delete
|
||||
</span>{" "}
|
||||
<span className="font-bold">{website.name}</span> and{" "}
|
||||
<span className="font-bold">all its data</span>, type{" "}
|
||||
<span className="font-bold">DELETE</span> below and confirm.
|
||||
</p>
|
||||
<input
|
||||
className="border rounded px-3 py-2 w-full mb-4"
|
||||
placeholder="Type DELETE to confirm"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteConfirmText("");
|
||||
}}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || deleteConfirmText !== "DELETE"}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete Website"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { WebsiteSettings } from "@/components/dashboard/WebsiteSettings";
|
||||
|
||||
export default function WebsiteSettingsPage(props: any) {
|
||||
const [id, setId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
try {
|
||||
const params = await Promise.resolve(props?.params);
|
||||
setId(params?.id || null);
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve params:", error);
|
||||
}
|
||||
};
|
||||
resolveParams();
|
||||
}, [props?.params]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{id ? <WebsiteSettings websiteId={id} /> : <div>Loading...</div>}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { AddWebsiteForm } from "@/components/dashboard/AddWebsiteForm";
|
||||
|
||||
export default function AddWebsitePage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="py-6">
|
||||
<AddWebsiteForm />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user