diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9478e85ba..c853bf3d8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,7 +32,7 @@ The repository is a monorepo for the VitNode framework, which includes a backend - **Package Manager:** Use `pnpm` for all installs/scripts - **Scripts:** - - `pnpm dev` (dev server), `pnpm build`, `pnpm lint`, `pnpm db:push`, `pnpm db:migrate`, `pnpm docker:dev` + - `pnpm dev` (dev server), `pnpm build`, `pnpm lint`, `pnpm db:migrate`, `pnpm docker:dev` - **CLI:** - Create apps/plugins via `pnpm create vitnode-app@canary` (see `packages/create-vitnode-app`) - CLI prompts for package manager, app mode, Biome, Docker, install (see `questions.ts`) diff --git a/.github/docs/prd.md b/.github/docs/prd.md deleted file mode 100644 index 44b46333d..000000000 --- a/.github/docs/prd.md +++ /dev/null @@ -1,287 +0,0 @@ -# VitNode - Extendable Framework for Building Applications with Next.js and Hono.js - -## Project Overview - -VitNode is a comprehensive framework designed to simplify and accelerate application development with Next.js and Hono.js. Built as a monorepo solution managed by Turborepo, VitNode provides a structured environment that makes development faster and less complex. The framework includes an integrated AdminCP and plugin system to extend its core functionality. - -## Main Problem - -Building applications with Next.js and Hono.js can be complex and time-consuming. Developers often face challenges in managing application structure, handling routing, and integrating various components seamlessly. Additionally, the lack of a unified framework leads to inconsistencies and difficulties in maintaining the codebase. - -Key problems VitNode solves: - -- Complexity in managing application structure -- Time-consuming creation of basic functionalities -- Lack of a consistent plugin system for extending functionality -- Difficulties in managing authorization and authentication -- Complicated configuration and integration of various components - -## Target Users - -VitNode is designed for individual developers and small teams who need a structured, extensible framework for building applications. Users are expected to have a basic understanding of JavaScript (with TypeScript) and Next.js, but don't need extensive expertise in complex framework architectures. - -## Core Features - -### Structure and Configuration - -- Monorepo structure using Turborepo with `apps`, `packages`, and `plugins` directories -- CLI tool for creating new projects (`create-vitnode-app`) -- Database migration system using Drizzle ORM with PostgreSQL -- Internationalization (i18n) support using next-intl -- Dark/Light mode support with system preference detection -- Environment-based configuration management - -### Plugin System - -- Monorepo-based plugin architecture with standardized structure -- Plugins can extend functionality by creating: - - New pages and routes with automatic navigation integration - - API endpoints with OpenAPI documentation - - AdminCP pages with role-based access control - - Database schema extensions with automatic migrations - - Custom UI components and layouts - - Email providers (SMTP, Resend, custom) -- Plugin hooks and events system for inter-plugin communication - -### CI/CD - -- Automated workflows using GitHub Actions: - - Code quality checks (Biome, TypeScript) - - Test suite execution with Vitest and Playwright - - Dependency security scanning with npm audit - - Automated builds and deployments to Vercel - - Database schema validation and migration checks - - Automated changelog generation and release notes - -### Authentication & Authorization - -- Multi-provider authentication system: - - Credentials (email/password) with hashing - - OAuth providers (Google, GitHub, Facebook, Discord) -- User registration with email verification -- Password reset with secure token generation -- Two-factor authentication (TOTP) -- Session management with secure cookies: - - Configurable session duration per user group - - Automatic session cleanup - - Cross-device session management -- Security features: - - CSRF protection with double-submit cookies - - XSS protection with content security policy - - Rate limiting on authentication endpoints - - Account lockout after failed attempts - -### Role Management - -- Hierarchical role system with inheritance -- AdminCP interface for comprehensive role management: - - Role CRUD operations with validation - - Permission matrix with granular controls - - Bulk role assignment and management -- Dynamic permission system: - - Resource-based permissions (read, write, delete, admin) - - Context-aware permissions (own content vs. all content) - - Plugin-defined custom permissions -- Role-based middleware for API and page protection - -### User Management - -- Comprehensive user administration: - - Advanced search and filtering (by role, status, registration date) - - Bulk operations (role assignment, status changes, deletion) - - User activity tracking and audit logs - - Profile management with avatar uploads -- User groups and organization support -- Flexible user profile fields with custom validation - -### API and Documentation - -- Full OpenAPI 3.0 specification with Swagger UI -- API versioning with backward compatibility -- Comprehensive documentation using Fumadocs: - - Interactive examples with code snippets - - Plugin development guides - - Deployment instructions - - Best practices and patterns -- Type-safe API client generation - -### Developer Tools - -- Integrated development environment: - - Hot reload for both frontend and backend - - Database query logging and profiling - - API request/response logging - - Performance monitoring with Core Web Vitals -- Debugging tools: - - React Developer Tools integration - - Database query inspector - - Authentication flow debugger -- Code generation tools: - - Component scaffolding - - API endpoint generation - - Database schema generation from models -- Logging system with structured logs: - - Log levels (debug, info, warn, error) - - Contextual logging with request/response metadata - -### File Management - -- Configurable file upload system: - - Local filesystem storage - - Cloud storage providers (AWS S3, Google Cloud, Azure) - - Image processing and optimization - - File type validation and security scanning -- Media library with organization features -- CDN integration for optimal performance - -### Content Management - -- Flexible content types with custom fields -- WYSIWYG editor with plugin support -- Content versioning and revision history -- Workflow management (draft, review, published) -- SEO optimization tools - -## Technical Architecture - -### Frontend Stack - -- Next.js 15 with App Router -- React 19 with Server Components -- TypeScript 5 with strict configuration -- Tailwind CSS 4 with Shadcn UI components -- Zod 4 for runtime validation -- React Hook Form 7 for form management -- Next-intl for internationalization - -### Backend Stack - -- Hono.js 4 for API development -- Drizzle ORM with PostgreSQL -- Zod OpenAPI for API documentation - -### Development Tools - -- Turborepo for monorepo management -- Vitest for unit testing -- Playwright for end-to-end testing -- Biome for code quality -- Docker for containerization - -## Features Planned for Future Releases - -The following features are planned for upcoming releases: - -- WebSocket support for real-time features -- Advanced caching strategies (Redis, Memcached) -- Enhanced rate limiting with Redis backend -- Advanced analytics and reporting -- Marketplace for community plugins -- Multi-tenant architecture support -- Advanced workflow automation -- AI-powered features (content generation, smart suggestions) - -## Success Criteria - -### Developer Experience - -- Developers should create a basic CRUD application within 30 minutes -- Plugin development should take less than 2 hours for basic functionality -- Framework adoption measured by: - - GitHub stars and community engagement - - Plugin ecosystem growth - - Developer feedback scores (target: 4.5/5) - -### Performance - -- Lighthouse scores of 95+ for all generated pages -- Time to First Byte (TTFB) under 200ms -- Largest Contentful Paint (LCP) under 1.5 seconds -- Cumulative Layout Shift (CLS) under 0.1 - -### Accessibility - -- WCAG 2.1 AA compliance for all UI components -- Screen reader compatibility testing -- Keyboard navigation support -- Color contrast ratios meeting accessibility standards - -### Deployment - -- One-click deployment to major platforms: - - Vercel with Supabase/PlanetScale - - AWS with RDS - - Google Cloud Platform - - Self-hosted with Docker Compose -- Deployment time under 5 minutes for basic applications - -### Documentation - -- Complete API documentation with interactive examples -- Step-by-step tutorials for common use cases -- Video tutorials for complex features -- Community-contributed examples and patterns -- Documentation satisfaction score of 4.7/5 - -## Developer Workflow - -The recommended developer workflow: - -1. **Project Creation** - - ```bash - npx create-vitnode-app@latest my-app - cd my-app - ``` - -2. **Development Setup** - - ```bash - pnpm install - pnpm db:push # Set up database schema - pnpm db:seed # Populate with initial data - ``` - -3. **Development** - - ```bash - pnpm dev # Start development servers - pnpm dev:docs # Start documentation server - ``` - -4. **Plugin Development** - - ```bash - pnpm create:plugin my-plugin - cd plugins/my-plugin - pnpm dev - ``` - -5. **Testing** - - ```bash - pnpm test # Run unit tests - pnpm test:e2e # Run end-to-end tests - pnpm test:coverage # Generate coverage report - ``` - -6. **Production Build** - - ```bash - pnpm build # Build all applications - pnpm start # Start production server - ``` - -7. **Deployment** - ```bash - pnpm deploy # Deploy to configured platform - ``` - -## Quality Assurance - -- Automated testing pipeline with 90%+ code coverage -- Performance monitoring with automated alerts -- Security scanning with dependency vulnerability checks -- Accessibility testing with automated tools -- Cross-browser compatibility testing -- Load testing for high-traffic scenarios diff --git a/.github/docs/tests_plan.md b/.github/docs/tests_plan.md deleted file mode 100644 index c0ef8ae9b..000000000 --- a/.github/docs/tests_plan.md +++ /dev/null @@ -1,101 +0,0 @@ -# VitNode Test Plan - -## 1. Introduction - -This document outlines the testing strategy for the VitNode framework. The goal is to ensure the reliability, functionality, and performance of the core framework (`packages/vitnode`), the web application (`apps/docs`), and associated plugins. We will utilize Vitest for unit and integration testing and Playwright for end-to-end testing. - -## 2. Goals - -- Ensure core framework features function as expected ([.github/docs/prd.md](.github/docs/prd.md)). -- Verify the stability and correctness of the AdminCP and user-facing application. -- Validate authentication (credentials, SSO: Google, GitHub, Facebook), authorization, and permission systems. -- Confirm database interactions and schema integrity. -- Ensure the plugin system allows for seamless extension of functionality (new pages, API endpoints, AdminCP pages, SSO providers, email providers). -- Maintain high code quality and prevent regressions. -- Achieve performance and accessibility targets ([.github/docs/prd.md](.github/docs/prd.md)). - -## 3. Testing Tools - -- **[Vitest](https://vitest.dev/):** For unit and integration tests. Chosen for its speed, ESM support, and compatibility with the Vite ecosystem (used by Next.js) and Hono.js. -- **[Playwright](https://playwright.dev/):** For end-to-end tests. Chosen for its cross-browser capabilities, reliability, and features like auto-waits and tracing. - -## 4. Types of Tests - -### 4.1. Unit Tests (Vitest) - -**Scope:** Test individual functions, components, and utilities in isolation. -**Location:** Primarily within `packages/vitnode` and utility directories in `apps/docs`. - -**Areas to Cover:** - -- **Core Utilities:** Functions in `packages/vitnode/src/lib`, helpers, etc. -- **API Helpers:** Route building, validation logic using `@hono/zod-openapi`. -- **UI Components:** Basic rendering tests, prop validation for components in `packages/vitnode/src/components` and `apps/docs/src/components`. -- **Configuration Loading:** Ensure `vitnode.config.ts` is loaded and parsed correctly. -- **Internationalization (i18n):** Test translation loading and formatting utilities. - -### 4.2. Integration Tests (Vitest) - -**Scope:** Test the interaction between different modules or layers of the application. -**Location:** Test files adjacent to the features they cover (e.g., API routes, server actions). - -**Areas to Cover:** - -- **API Endpoints:** Test request handling, validation, middleware execution, and response generation for Hono.js routes defined in `packages/vitnode/src/api`. Example: [`testRoute`](packages/vitnode/src/api/modules/users/routes/test.route.ts). -- **Server Actions:** Test form submissions, data mutations, and interactions with the database within `apps/docs`. -- **Database Interactions:** Verify Drizzle ORM queries, schema interactions, and data integrity (using a test database). -- **Authentication Logic:** Test credential verification, session creation/validation (including durations), email verification, password reset, and SSO provider interactions (Google, GitHub, Facebook). -- **Plugin Integration:** Test how core systems interact with plugin-provided extensions (routes, hooks, etc.). - -### 4.3. End-to-End Tests (Playwright) - -**Scope:** Simulate real user scenarios by interacting with the application through a browser. -**Location:** A dedicated `e2e` or `tests` directory at the root or within `apps/docs`. - -**Areas to Cover:** - -- **Authentication Flows:** - - User registration - - Login with credentials - - Login with SSO providers (Google, GitHub, Facebook - as defined in [.github/docs/prd.md](.github/docs/prd.md)) - - Password reset flow - - Email verification flow - - Logout -- **AdminCP Functionality:** - - Navigating the AdminCP interface - - User Management: Create, Read, Update, Delete (CRUD) users, search, filter ([.github/docs/prd.md](.github/docs/prd.md)) - - Role Management: CRUD roles and assign permissions ([.github/docs/prd.md](.github/docs/prd.md)) - - Plugin Management (if applicable) - - Viewing system settings/information -- **Main Application Flows:** - - Navigating public pages - - User profile updates (password, email, avatar) - - Content interaction (e.g., viewing blog posts if `plugins/blog` is enabled) -- **Plugin-Specific Flows:** E2E tests for critical user journeys introduced by plugins. -- **Responsiveness:** Basic checks across different viewport sizes. -- **Accessibility:** Automated accessibility checks using Playwright's integrations to meet WCAG 2.1 guidelines. - -## 5. Test Environment - -- **Database:** A separate PostgreSQL database instance for testing, managed via Docker ([docker-compose.yml](docker-compose.yml)). Schema pushed using `pnpm db:push`. -- **Environment Variables:** A specific `.env.test` file or equivalent mechanism for test-specific configurations (e.g., database connection strings, API keys for test accounts). -- **Seeding:** Scripts to seed the test database with necessary initial data (roles, languages, default users, permissions, etc.) before test runs. - -## 6. Running Tests - -- **Unit/Integration:** `pnpm test` or specific Vitest commands targeting files/directories. (Note: `README.md` lists `pnpm test` but not specific unit/integration scripts yet). -- **End-to-End:** `pnpm test:e2e` (script to be defined). Playwright command to run the E2E test suite. -- **All Tests:** `pnpm test` (script to be defined, potentially running both Vitest and Playwright suites). - -## 7. CI/CD Integration - -- Tests will be automatically executed on pull requests and pushes to the main branch using GitHub Actions ([.github/workflows/](.github/workflows/)). -- Test results and coverage reports will be generated and made available. -- Failing tests will block merging/deployment as per CI/CD configuration ([.github/docs/prd.md](.github/docs/prd.md)). -- CI/CD pipeline includes linting, formatting, security scanning, build checks, and potentially deployment steps. - -## 8. Reporting - -- Test results will be reported in the CI/CD pipeline output. -- Code coverage reports will be generated (using Vitest's coverage capabilities) and potentially uploaded as artifacts or integrated with services like Codecov. -- Playwright reports (HTML, traces) will be generated for E2E test runs, especially for failures, to aid debugging. diff --git a/README.md b/README.md index 193a0c5d9..7e966be8c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ - `pnpm start` - Start production server - `pnpm lint` - Check code quality - `pnpm lint:fix` - Fix code quality issues -- `pnpm db:push` - Push database schema changes - `pnpm db:migrate` - Run database migrations - `pnpm dev:email` - Start email development server diff --git a/apps/api/.env.example b/apps/api/.env.example index 5f176b2c9..d1900fdd5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -2,6 +2,9 @@ POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode NEXT_PUBLIC_WEB_URL=http://localhost:3000 +# === CRON Secret for Internal API Calls === +CRON_SECRET=your-secure-cron-secret-key + # === Docker Database Postgres === POSTGRES_USER=root POSTGRES_PASSWORD=root diff --git a/apps/api/package.json b/apps/api/package.json index 8a101af0d..400717a5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,8 +4,6 @@ "private": true, "type": "module", "scripts": { - "db:push": "vitnode push", - "db:migrate": "vitnode migrate", "init": "vitnode init --api", "dev": "tsx watch src/index.ts", "dev:email": "email dev --dir src/emails", diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index cefac4fa4..8eba226a1 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -192,6 +192,36 @@ "home_page": "Home Page", "debug": "Debug Panel", "log_out": "Log Out" + }, + "advanced": { + "title": "Advanced", + "cron": "Cron Jobs" + } + } + }, + "advanced": { + "cron": { + "title": "Cron Jobs", + "desc": "Manage and monitor scheduled tasks.", + "list": { + "name": "Name", + "pluginId": "Plugin ID", + "module": "Module", + "schedule": "Schedule", + "lastRun": { + "title": "Last Run", + "never": "Never" + }, + "nextRun": { + "title": "Next Run", + "never": "Never" + }, + "actions": { + "runNow": { + "label": "Run Job Now", + "success": "Cron job executed successfully." + } + } } } }, diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 8cd02f224..e823b9781 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -3,6 +3,9 @@ POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode NEXT_PUBLIC_API_URL=http://localhost:3000 NEXT_PUBLIC_WEB_URL=http://localhost:3000 +# === CRON Secret for Internal API Calls === +CRON_SECRET=your-secure-cron-secret-key + # === Docker Database Postgres === POSTGRES_USER=root POSTGRES_PASSWORD=root diff --git a/apps/docs/content/docs/dev/api/modules.mdx b/apps/docs/content/docs/dev/api/modules.mdx index 339011195..c164d7dc8 100644 --- a/apps/docs/content/docs/dev/api/modules.mdx +++ b/apps/docs/content/docs/dev/api/modules.mdx @@ -8,14 +8,14 @@ description: xxx Think of modules as containers for related API endpoints. They help organize your routes logically - perfect for keeping your sanity intact! ```ts title="plugins/blog/src/api/modules/categories/categories.module.ts" -import { buildModule } from '@vitnode/core/api/lib/module'; +import { buildModule } from "@vitnode/core/api/lib/module"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; export const categoriesModule = buildModule({ pluginId: CONFIG_PLUGIN.id, - name: 'categories', - routes: [], // We'll populate this soon! + name: "categories", + routes: [] // We'll populate this soon! }); ``` @@ -24,17 +24,17 @@ export const categoriesModule = buildModule({ Want to create a module hierarchy? VitNode's got your back! Nested modules are perfect for complex APIs. ```ts title="plugins/blog/src/api/modules/categories/categories.module.ts" -import { buildModule } from '@vitnode/core/api/lib/module'; +import { buildModule } from "@vitnode/core/api/lib/module"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; -import { postsModule } from './posts/posts.module'; // [!code ++] +import { postsModule } from "./posts/posts.module"; // [!code ++] export const categoriesModule = buildModule({ pluginId: CONFIG_PLUGIN.id, - name: 'categories', + name: "categories", routes: [], - modules: [postsModule], // [!code ++] + modules: [postsModule] // [!code ++] }); ``` @@ -43,16 +43,16 @@ This creates a structure: `/api/{plugin_id}/categories/posts/*` ## Connecting Modules to the API ```ts title="plugins/blog/src/config.api.ts" -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'; +import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; -import { categoriesModule } from './api/modules/categories/categories.module'; // [!code ++] +import { categoriesModule } from "./api/modules/categories/categories.module"; // [!code ++] export const blogApiPlugin = () => { return buildApiPlugin({ - ...CONFIG_PLUGIN, - modules: [categoriesModule], // [!code ++] + pluginId: CONFIG_PLUGIN.pluginId, + modules: [categoriesModule] // [!code ++] }); }; ``` diff --git a/apps/docs/content/docs/dev/api/routes.mdx b/apps/docs/content/docs/dev/api/routes.mdx index f631d8779..b54f65318 100644 --- a/apps/docs/content/docs/dev/api/routes.mdx +++ b/apps/docs/content/docs/dev/api/routes.mdx @@ -8,59 +8,59 @@ description: xxx Now for the fun part - creating actual endpoints! Each route is a small but mighty function that handles HTTP requests. ```ts title="plugins/blog/src/api/modules/categories/routes/get.route.ts" -import { z } from '@hono/zod-openapi'; -import { buildRoute } from '@vitnode/core/api/lib/route'; +import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@vitnode/core/api/lib/route"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; export const getCategoriesRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { - method: 'get', - path: '/', + method: "get", + path: "/", responses: { 200: { content: { - 'application/json': { + "application/json": { schema: z.object({ categories: z.array( z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), - }), - ), - }), - }, + description: z.string().optional() + }) + ) + }) + } }, - description: 'Successfully retrieved categories', - }, - }, + description: "Successfully retrieved categories" + } + } }, - handler: c => { + handler: (c) => { // Your business logic goes here return c.json({ categories: [ - { id: '1', name: 'Technology', description: 'All things tech' }, - { id: '2', name: 'Lifestyle' }, - ], + { id: "1", name: "Technology", description: "All things tech" }, + { id: "2", name: "Lifestyle" } + ] }); - }, + } }); ``` ### Connecting Routes to Modules ```ts title="plugins/blog/src/api/modules/categories/categories.module.ts" -import { buildModule } from '@vitnode/core/api/lib/module'; +import { buildModule } from "@vitnode/core/api/lib/module"; -import { CONFIG_PLUGIN } from '@/config'; -import { getCategoriesRoute } from './routes/get.route'; // [!code ++] +import { CONFIG_PLUGIN } from "@/config"; +import { getCategoriesRoute } from "./routes/get.route"; // [!code ++] export const categoriesModule = buildModule({ pluginId: CONFIG_PLUGIN.id, - name: 'categories', - routes: [getCategoriesRoute], // [!code ++] + name: "categories", + routes: [getCategoriesRoute] // [!code ++] }); ``` @@ -71,60 +71,60 @@ export const categoriesModule = buildModule({ Path parameters are perfect when you need to identify specific resources. They're like the ID card of your API endpoints! ```ts title="plugins/blog/src/api/modules/categories/routes/get_by_id.route.ts" -import { z } from '@hono/zod-openapi'; -import { buildRoute } from '@vitnode/core/api/lib/route'; -import { HTTPException } from 'hono/http-exception'; +import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { HTTPException } from "hono/http-exception"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; export const getCategoryByIdRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { - method: 'get', + method: "get", // [!code highlight] - path: '/{id}', // Dynamic path parameter + path: "/{id}", // Dynamic path parameter request: { // [!code highlight:6] params: z.object({ id: z.string().openapi({ - description: 'Unique identifier for the category', - example: 'tech-category-123', - }), - }), + description: "Unique identifier for the category", + example: "tech-category-123" + }) + }) }, responses: { 200: { content: { - 'application/json': { + "application/json": { schema: z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), - }), - }, + description: z.string().optional() + }) + } }, - description: 'Category details retrieved successfully', + description: "Category details retrieved successfully" }, 404: { - description: 'Category not found', - }, - }, + description: "Category not found" + } + } }, - handler: c => { + handler: (c) => { // [!code highlight] - const { id } = c.req.valid('param'); // Extract the path parameter + const { id } = c.req.valid("param"); // Extract the path parameter // Simulate database lookup - if (id === 'nonexistent') { + if (id === "nonexistent") { throw new HTTPException(404); } return c.json({ id, name: `Category ${id}`, - description: 'A fantastic category for amazing content', + description: "A fantastic category for amazing content" }); - }, + } }); ``` @@ -133,67 +133,65 @@ export const getCategoryByIdRoute = buildRoute({ Query parameters are your best friends for filtering, searching, and pagination. They make your API flexible and user-friendly! ```ts title="plugins/blog/src/api/modules/categories/routes/search.route.ts" -import { z } from '@hono/zod-openapi'; -import { buildRoute } from '@vitnode/core/api/lib/route'; +import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@vitnode/core/api/lib/route"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; export const searchCategoriesRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { - method: 'get', - path: '/search', + method: "get", + path: "/search", request: { // [!code highlight:6] query: z.object({ search: z.string().optional().openapi({ - description: 'Search term to filter categories', - example: 'technology', - }), - }), + description: "Search term to filter categories", + example: "technology" + }) + }) }, responses: { 200: { content: { - 'application/json': { + "application/json": { schema: z.object({ categories: z.array( z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), - }), + description: z.string().optional() + }) ), - total: z.number(), - }), - }, + total: z.number() + }) + } }, - description: 'Search results with pagination info', - }, - }, + description: "Search results with pagination info" + } + } }, - handler: c => { - const { search } = c.req.valid('query'); // [!code highlight] + handler: (c) => { + const { search } = c.req.valid("query"); // [!code highlight] // Your search logic here const mockResults = [ - { id: '1', name: 'Technology', description: 'Tech-related posts' }, - { id: '2', name: 'Lifestyle' }, + { id: "1", name: "Technology", description: "Tech-related posts" }, + { id: "2", name: "Lifestyle" } ]; const filteredResults = search - ? mockResults.filter(cat => - cat.name.toLowerCase().includes(search.toLowerCase()), - ) + ? mockResults.filter((cat) => cat.name.toLowerCase().includes(search.toLowerCase())) : mockResults; const paginatedResults = filteredResults.slice(offset, offset + limit); return c.json({ categories: paginatedResults, - total: filteredResults.length, + total: filteredResults.length }); - }, + } }); ``` @@ -202,78 +200,78 @@ export const searchCategoriesRoute = buildRoute({ When you need to send complex data (creating, updating), request bodies are your go-to solution. Perfect for forms and JSON payloads! ```ts title="plugins/blog/src/api/modules/categories/routes/create.route.ts" -import { z } from '@hono/zod-openapi'; -import { buildRoute } from '@vitnode/core/api/lib/route'; +import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@vitnode/core/api/lib/route"; -import { CONFIG_PLUGIN } from '@/config'; +import { CONFIG_PLUGIN } from "@/config"; const createCategorySchema = z.object({ name: z.string().min(1).max(100).openapi({ - description: 'Name of the category', - example: 'Web Development', + description: "Name of the category", + example: "Web Development" }), description: z.string().optional().openapi({ - description: 'Optional description for the category', - example: 'Everything about building websites and web applications', + description: "Optional description for the category", + example: "Everything about building websites and web applications" }), color: z .string() .regex(/^#[0-9A-F]{6}$/i) .optional() .openapi({ - description: 'Hex color code for the category', - example: '#3B82F6', - }), + description: "Hex color code for the category", + example: "#3B82F6" + }) }); export const createCategoryRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { - method: 'post', - path: '/', + method: "post", + path: "/", request: { // [!code highlight:9] body: { content: { - 'application/json': { - schema: createCategorySchema, - }, + "application/json": { + schema: createCategorySchema + } }, - description: 'Category data to create', - required: true, - }, + description: "Category data to create", + required: true + } }, responses: { 201: { content: { - 'application/json': { + "application/json": { schema: z.object({ id: z.string(), name: z.string(), description: z.string().optional(), color: z.string().optional(), - createdAt: z.string(), - }), - }, + createdAt: z.string() + }) + } }, - description: 'Category created successfully', + description: "Category created successfully" }, 400: { - description: 'Invalid input data', - }, - }, + description: "Invalid input data" + } + } }, - handler: async c => { - const data = c.req.valid('json'); // [!code highlight] + handler: async (c) => { + const data = c.req.valid("json"); // [!code highlight] // Simulate category creation const newCategory = { id: `cat_${Date.now()}`, ...data, - createdAt: new Date().toISOString(), + createdAt: new Date().toISOString() }; return c.json(newCategory, 201); - }, + } }); ``` diff --git a/apps/docs/content/docs/dev/captcha/overview.mdx b/apps/docs/content/docs/dev/captcha/index.mdx similarity index 71% rename from apps/docs/content/docs/dev/captcha/overview.mdx rename to apps/docs/content/docs/dev/captcha/index.mdx index 057fa5bb2..0967d5b84 100644 --- a/apps/docs/content/docs/dev/captcha/overview.mdx +++ b/apps/docs/content/docs/dev/captcha/index.mdx @@ -1,5 +1,5 @@ --- -title: Overview +title: Captcha description: Protect your forms and API call with captcha validation. --- @@ -13,11 +13,7 @@ VitNode supports multiple captcha providers. You can choose the one that fits yo description="By Cloudflare" href="/docs/guides/captcha/cloudflare" /> - + If you need more providers, feel free to open a **Feature Request** on our [GitHub repository](https://github.com/aXenDeveloper/vitnode/issues) :) @@ -34,17 +30,17 @@ In this example, we will show you how to use captcha in your forms. We will use Add `withCaptcha` to your route config to enable captcha validation for this route. ```ts title="plugins/{plugin_name}/src/routes/example.ts" -import { buildRoute } from '@vitnode/core/api/lib/route'; +import { buildRoute } from "@vitnode/core/api/lib/route"; export const exampleRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { - method: 'post', - description: 'Create a new user', - path: '/sign_up', - withCaptcha: true, // [!code ++] + method: "post", + description: "Create a new user", + path: "/sign_up", + withCaptcha: true // [!code ++] }, - handler: async c => {}, + handler: async (c) => {} }); ``` @@ -56,7 +52,7 @@ export const exampleRoute = buildRoute({ Get captcha config from middleware API in your view and pass it to your `'use client';` component. ```tsx title="plugins/{plugin_name}/src/app/sing_up/page.tsx" -import { getMiddlewareApi } from '@vitnode/core/lib/api/get-middleware-api'; // [!code ++] +import { getMiddlewareApi } from "@vitnode/core/lib/api/get-middleware-api"; // [!code ++] export const SignUpView = async () => { const { captcha } = await getMiddlewareApi(); // [!code ++] @@ -73,14 +69,14 @@ export const SignUpView = async () => { Get the `captcha` config from the props and pass it to the `AutoForm` component. This will render the captcha widget in your form. ```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" -'use client'; +"use client"; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; export const FormSignUp = ({ - captcha, // [!code ++] + captcha // [!code ++] }: { - captcha: z.infer['captcha']; // [!code ++] + captcha: z.infer["captcha"]; // [!code ++] }) => { return ( @@ -106,27 +102,27 @@ export const FormSignUp = ({ In your form submission handler, you can get the `captchaToken` from the form submission context and pass it to your mutation API. ```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" -'use client'; +"use client"; import { AutoForm, - type AutoFormOnSubmit, // [!code ++] -} from '@vitnode/core/components/form/auto-form'; + type AutoFormOnSubmit // [!code ++] +} from "@vitnode/core/components/form/auto-form"; export const FormSignUp = ({ - captcha, + captcha }: { - captcha: z.infer['captcha']; + captcha: z.infer["captcha"]; }) => { const onSubmit: AutoFormOnSubmit = async ( values, form, - { captchaToken }, // [!code ++] + { captchaToken } // [!code ++] ) => { // Call your mutation API with captcha token await mutationApi({ ...values, - captchaToken, // [!code ++] + captchaToken // [!code ++] }); // Handle success or error @@ -146,25 +142,25 @@ export const FormSignUp = ({ Next, you need to set `captchaToken` in your mutation API call. This token is provided by the `AutoForm` component when the form is submitted. ```tsx title="plugins/{plugin_name}/src/components/form/sign-up/mutation-api.ts" -'use server'; +"use server"; -import type { z } from 'zod'; +import type { z } from "zod"; -import { fetcher } from '@vitnode/core/lib/fetcher'; +import { fetcher } from "@vitnode/core/lib/fetcher"; export const mutationApi = async ({ captchaToken, // [!code ++] ...input - // [!code ++] -}: z.infer & { captchaToken: string }) => { +}: // [!code ++] +z.infer & { captchaToken: string }) => { const res = await fetcher(usersModule, { - path: '/sign_up', - method: 'post', - module: 'users', + path: "/sign_up", + method: "post", + module: "users", captchaToken, // [!code ++] args: { - body: input, - }, + body: input + } }); if (res.status !== 201) { @@ -190,17 +186,17 @@ If you want to use captcha in your custom form or somewhere else, follow these s ### Activate captcha in route ```ts title="plugins/{plugin_name}/src/routes/example.ts" -import { buildRoute } from '@vitnode/core/api/lib/route'; +import { buildRoute } from "@vitnode/core/api/lib/route"; export const exampleRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { - method: 'post', - description: 'Create a new user', - path: '/sign_up', - withCaptcha: true, // [!code ++] + method: "post", + description: "Create a new user", + path: "/sign_up", + withCaptcha: true // [!code ++] }, - handler: async c => {}, + handler: async (c) => {} }); ``` @@ -210,7 +206,7 @@ export const exampleRoute = buildRoute({ ### Get config from middleware API ```tsx title="plugins/{plugin_name}/src/app/sing_up/page.tsx" -import { getMiddlewareApi } from '@vitnode/core/lib/api/get-middleware-api'; // [!code ++] +import { getMiddlewareApi } from "@vitnode/core/lib/api/get-middleware-api"; // [!code ++] export const SignUpView = async () => { const { captcha } = await getMiddlewareApi(); // [!code ++] @@ -227,14 +223,14 @@ export const SignUpView = async () => { Inside your client component, use the `useCaptcha` hook to handle captcha rendering and validation. Remember to add `div` with `id="vitnode_captcha"` where you want the captcha widget to appear. ```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" -'use client'; +"use client"; -import { AutoForm } from '@vitnode/core/components/form/auto-form'; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; export const FormSignUp = ({ - captcha, // [!code ++] + captcha // [!code ++] }: { - captcha: z.infer['captcha']; // [!code ++] + captcha: z.infer["captcha"]; // [!code ++] }) => { // [!code ++] const { isReady, getToken, onReset } = useCaptcha(captcha); @@ -242,7 +238,7 @@ export const FormSignUp = ({ const onSubmit = async () => { await mutationApi({ // ...other values, - captchaToken: await getToken(), // [!code ++] + captchaToken: await getToken() // [!code ++] }); // Handle success or error @@ -268,23 +264,23 @@ export const FormSignUp = ({ ### Submit form with captcha ```tsx title="plugins/{plugin_name}/src/components/form/sign-up/mutation-api.ts" -'use server'; +"use server"; -import type { z } from 'zod'; +import type { z } from "zod"; -import { fetcher } from '@vitnode/core/lib/fetcher'; +import { fetcher } from "@vitnode/core/lib/fetcher"; export const mutationApi = async ({ - captchaToken, // [!code ++] + captchaToken // [!code ++] }: { // [!code ++] captchaToken; }) => { await fetcher(usersModule, { - path: '/test', - method: 'post', - module: 'blog', - captchaToken, // [!code ++] + path: "/test", + method: "post", + module: "blog", + captchaToken // [!code ++] }); }; ``` diff --git a/apps/docs/content/docs/dev/captcha/meta.json b/apps/docs/content/docs/dev/captcha/meta.json deleted file mode 100644 index 0c7e112e0..000000000 --- a/apps/docs/content/docs/dev/captcha/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Captcha", - "pages": ["overview", "---Adapters---", "..."] -} diff --git a/apps/docs/content/docs/dev/contribution.mdx b/apps/docs/content/docs/dev/contribution.mdx index e64ff982d..2affb754f 100644 --- a/apps/docs/content/docs/dev/contribution.mdx +++ b/apps/docs/content/docs/dev/contribution.mdx @@ -99,7 +99,6 @@ vitnode/ - We use TypeScript for type safety ([TypeScript Docs](https://www.typescriptlang.org/)) - Follow ESM (ECMAScript Modules) conventions ([ESM Guide](https://nodejs.org/api/esm.html)) - Respect the Biome configuration in each workspace ([Biome](https://biomejs.dev/)) -- Format your code with Prettier ([Prettier](https://prettier.io/)) - Use React Server Components where appropriate - Write meaningful commit messages ([Conventional Commits](https://www.conventionalcommits.org/)) @@ -108,7 +107,6 @@ vitnode/ If you make changes to the database schema: ```bash -pnpm db:push # Push schema changes pnpm db:migrate # Run migrations ``` diff --git a/apps/docs/content/docs/dev/cron/index.mdx b/apps/docs/content/docs/dev/cron/index.mdx new file mode 100644 index 000000000..5be6d1c73 --- /dev/null +++ b/apps/docs/content/docs/dev/cron/index.mdx @@ -0,0 +1,100 @@ +--- +title: CRON Jobs +description: Automate and manage recurring tasks in your VitNode app with cron jobs — perfect for cleanups, emails, reports, and more. +--- + +## Adapters + +Before you can use cron functionality, you need to provide an adapter to your application. + + + + + + +## Custom adapter + +VitNode supports custom cron adapters, allowing you to integrate with various scheduling libraries or services. + + + + +### Create your custom adapter + +As an example we will create a custom adapter using the popular `node-cron` library. + +```ts +import { schedule } from "node-cron"; +import { type CronAdapter, handleCronJobs } from "@/api/lib/cron"; + +export const NodeCronAdapter = (): CronAdapter => { + return { + schedule() { + schedule("*/1 * * * *", async () => { + await handleCronJobs(); // [!code ++] + }); + } + }; +}; +``` + + + + + +### Integrate the adapter into your application + +```ts title="src/vitnode.api.config.ts" +import { NodeCronAdapter } from "./path/to/your/custom/node-cron.adapter"; + +export const vitNodeApiConfig = buildApiConfig({ + cronAdapter: NodeCronAdapter() +}); +``` + + + + + +## Restart server + +After making these changes, stop your server (if it's running) and restart it to apply the new configuration. + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + +That's it — your app now has a built-in task scheduler, ready to handle cron jobs with standard cron expressions. + + + + +## Check Your Cron Jobs + +You can check your cron jobs in AdminCP under **Core => Advanced => Cron Jobs**. + + + + diff --git a/apps/docs/content/docs/dev/cron/meta.json b/apps/docs/content/docs/dev/cron/meta.json new file mode 100644 index 000000000..f5df12b36 --- /dev/null +++ b/apps/docs/content/docs/dev/cron/meta.json @@ -0,0 +1,4 @@ +{ + "title": "CRON Jobs", + "pages": ["rest-api", "..."] +} diff --git a/apps/docs/content/docs/dev/cron/node-cron.mdx b/apps/docs/content/docs/dev/cron/node-cron.mdx new file mode 100644 index 000000000..cfe76f380 --- /dev/null +++ b/apps/docs/content/docs/dev/cron/node-cron.mdx @@ -0,0 +1,84 @@ +--- +title: Node CRON +description: In-memory tiny task scheduler in pure JavaScript for node.js based on GNU crontab. +--- + +This adapter lets you run scheduled jobs directly inside your Node.js app. It's simple, lightweight, and doesn't require any external services — great for when you just need cron tasks running locally or in memory. + + + This documentation is for self-hosted VitNode instances only. You cannot use this if you are + planning to deploy your application to the cloud. + + + + +## Installation + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun i node-cron +``` + +```bash tab="pnpm" +pnpm i node-cron +``` + +```bash tab="npm" +npm i node-cron +``` + + + + + +## Usage + +```ts title="src/vitnode.api.config.ts" +import { NodeCronAdapter } from "@vitnode/core/api/adapters/cron/node-cron.adapter"; +``` + +```ts title="src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + cronAdapter: NodeCronAdapter() +}); +``` + + + + + +## Restart server + +After making these changes, stop your server (if it's running) and restart it to apply the new configuration. + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + +That's it — your app now has a built-in task scheduler, ready to handle cron jobs with standard cron expressions. + + + + +## Check Your Cron Jobs + +You can check your cron jobs in AdminCP under **Core => Advanced => Cron Jobs**. + + + + diff --git a/apps/docs/content/docs/dev/cron/rest-api.mdx b/apps/docs/content/docs/dev/cron/rest-api.mdx new file mode 100644 index 000000000..e4d6e1b95 --- /dev/null +++ b/apps/docs/content/docs/dev/cron/rest-api.mdx @@ -0,0 +1,68 @@ +--- +title: REST API +description: Run cron jobs by triggering REST API endpoints from an external scheduler. +--- + +This method lets you use external services to manage and run your cron jobs through simple HTTP requests. It's flexible and works with many providers, so you can pick the scheduling tool that best fits your infrastructure. + + + +## Add a Secret Key + +Define a secret key that will be required for authentication when calling your cron endpoint. + +Add it to your `.env` file: + +```bash title=".env" +CRON_SECRET=your_secret_key +``` + + + We recommend using a random string of at least **16 characters** for better security. + + + + + +## Configure an External Scheduler + +Next, configure a scheduler that will send HTTP requests to your cron endpoint. Most modern platforms allow you to set up scheduled jobs that call URLs at defined intervals. Some popular options include: + +- [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs/quickstart) +- [GitHub Actions](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) +- [AWS Lambda with CloudWatch Events](https://docs.aws.amazon.com/lambda/latest/dg/services-cloudwatchevents.html) +- [Google Cloud Functions with Cloud Scheduler](https://cloud.google.com/scheduler/docs/tut-pub-sub) +- [EasyCron](https://www.easycron.com/) + + + You need to set schedule for every minute to ensure that your cron jobs are executed on time. + +Make sure to include the `Authorization` header with the value `Bearer {your_key}` in your requests, where `{your_key}` is the value of the `CRON_SECRET` variable you set in your `.env` file. + + + + + + + +## Example Request + +Here's an example of how to set up a cron job that runs every minute using `curl`: + +```bash title="cURL Example" +curl -X POST https://your-domain.com/api/cron \ + -H "Authorization {{your_key}}" +``` + +Replace `https://your-domain.com/api/cron` with your actual domain and `{{your_key}}` with the secret key you defined earlier. + + + + +## Check Your Cron Jobs + +You can check your cron jobs in AdminCP under **Core => Advanced => Cron Jobs**. + + + + diff --git a/apps/docs/content/docs/dev/database/index.mdx b/apps/docs/content/docs/dev/database/index.mdx index d02905168..001f0da6a 100644 --- a/apps/docs/content/docs/dev/database/index.mdx +++ b/apps/docs/content/docs/dev/database/index.mdx @@ -10,14 +10,14 @@ VitNode plugins seamlessly integrate with databases using [Drizzle ORM](https:// Create your database schema in the `database` directory of your plugin. Each table should be defined in its own file for better organization. ```ts title="plugins/{plugin_name}/src/database/categories.ts" -import { pgTable, serial, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, serial, timestamp } from "drizzle-orm/pg-core"; -export const blog_categories = pgTable('blog_categories', { +export const blog_categories = pgTable("blog_categories", { id: serial().primaryKey(), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp() .notNull() - .$onUpdate(() => new Date()), + .$onUpdate(() => new Date()) }); ``` @@ -27,20 +27,20 @@ Access the database in your plugin handlers using `c.get('database')` from the H ```ts title="plugins/{plugin_name}/src/routes/posts.ts" export const postsRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: {}, - handler: async c => { + handler: async (c) => { // [!code ++:7] const data = await c - .get('database') + .get("database") .select({ id: blog_posts.id, - title: blog_posts.title, + title: blog_posts.title }) .from(blog_posts); return c.json(data); - }, + } }); ``` @@ -52,7 +52,7 @@ VitNode provides convenient commands for managing your database schema and migra Generate migration files when you modify your database schema: -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; @@ -70,27 +70,7 @@ npm run db:migrate -### Pushing Schema Changes - -Apply your schema changes directly to the database: - - - -```bash tab="bun" -bun db:push -``` - -```bash tab="pnpm" -pnpm db:push -``` - -```bash tab="npm" -npm run db:push -``` - - - - VitNode automatically runs migrations and schema updates when you start your - application in development mode, keeping your database in sync with your code. + VitNode automatically runs migrations and schema updates when you start your application in + development mode, keeping your database in sync with your code. diff --git a/apps/docs/content/docs/dev/database/pagination.mdx b/apps/docs/content/docs/dev/database/pagination.mdx index fad97a68b..b7f86a41f 100644 --- a/apps/docs/content/docs/dev/database/pagination.mdx +++ b/apps/docs/content/docs/dev/database/pagination.mdx @@ -12,73 +12,71 @@ VitNode uses cursor-based pagination for optimal performance with large datasets ### Basic Usage ```ts -import { buildRoute } from '@/api/lib/route'; -import { dbClient } from '@/database/client'; -import { users } from '@/database/schema/users'; -import { z } from 'zod'; - +import z from "zod"; +import { buildRoute } from "@/api/lib/route"; import { withPagination, zodPaginationPageInfo, - zodPaginationQuery, -} from '@/api/lib/with-pagination'; + zodPaginationQuery +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_cron } from "@/database/cron"; -export const routeUsersGet = buildRoute({ - pluginId: 'core', +export const getCronsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, route: { - path: '/users', - method: 'get', - description: 'Get users list', + method: "get", + description: "Get Admin Cron Logs", + path: "/", request: { query: zodPaginationQuery.extend({ - order: z.enum(['asc', 'desc']).optional(), - orderBy: z.enum(['id', 'username', 'createdAt']).optional(), - }), + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(["lastRun"]).optional() + }) }, responses: { 200: { content: { - 'application/json': { + "application/json": { schema: z.object({ edges: z.array( z.object({ id: z.number(), - username: z.string(), - email: z.string(), createdAt: z.date(), - }), + name: z.string(), + description: z.string().nullable(), + pluginId: z.string(), + module: z.string(), + lastRun: z.date().nullable() + }) ), - pageInfo: zodPaginationPageInfo, - }), - }, + pageInfo: zodPaginationPageInfo + }) + } }, - description: 'List of users', - }, - }, + description: "List of cron jobs" + } + } }, - handler: async c => { - const query = c.req.valid('query'); + handler: async (c) => { + const query = c.req.valid("query"); const data = await withPagination({ params: { - query, + query }, - primaryCursor: users.id, // Primary key used for pagination + c, + primaryCursor: core_cron.id, query: async ({ limit, where, orderBy }) => - await dbClient - .select() - .from(users) - .where(where) - .orderBy(orderBy) - .limit(limit), - table: users, + await c.get("db").select().from(core_cron).where(where).orderBy(orderBy).limit(limit), + table: core_cron, orderBy: { - column: query.orderBy ? users[query.orderBy] : users.createdAt, - order: query.order ?? 'desc', - }, + column: query.orderBy ? core_cron[query.orderBy] : core_cron.lastRun, + order: query.order ?? "desc" + } }); return c.json(data); - }, + } }); ``` @@ -106,7 +104,7 @@ VitNode provides pre-defined Zod schemas for pagination: const zodPaginationQuery = z.object({ cursor: z.string().optional(), first: z.string().transform(Number).optional(), - last: z.string().transform(Number).optional(), + last: z.string().transform(Number).optional() }); // Example of zodPaginationPageInfo @@ -114,7 +112,7 @@ const zodPaginationPageInfo = z.object({ startCursor: z.string().nullable(), endCursor: z.string().nullable(), hasNextPage: z.boolean(), - hasPreviousPage: z.boolean(), + hasPreviousPage: z.boolean() }); ``` @@ -129,13 +127,13 @@ When fetching data from the API, include the pagination parameters in your reque ```tsx const query = await searchParams; // Assume searchParams is a Promise from the Next.js page context const res = await fetcher(userModule, { - path: '/users', - method: 'get', - module: 'user', + path: "/users", + method: "get", + module: "user", args: { - query, + query }, - withPagination: true, // Important flag for pagination + withPagination: true // Important flag for pagination }); ``` @@ -148,7 +146,7 @@ export interface SearchParamsDataTable { cursor?: string; first?: string; last?: string; - order?: 'asc' | 'desc'; + order?: "asc" | "desc"; orderBy?: keyof DataTableTMin; } ``` @@ -158,45 +156,42 @@ export interface SearchParamsDataTable { Here's a complete example showing how to implement pagination in a Next.js page: ```tsx -import { middlewareModule } from '@/api/modules/middleware/middleware.module'; -import { - DataTable, - SearchParamsDataTable, -} from '@vitnode/core/components/table/data-table'; -import { fetcher } from '@vitnode/core/lib/fetcher'; +import { middlewareModule } from "@/api/modules/middleware/middleware.module"; +import { DataTable, SearchParamsDataTable } from "@vitnode/core/components/table/data-table"; +import { fetcher } from "@vitnode/core/lib/fetcher"; export const UsersAdminView = async ({ - searchParams, + searchParams }: { searchParams: Promise; }) => { const query = await searchParams; const res = await fetcher(middlewareModule, { - path: '/users', - method: 'get', - module: 'middleware', + path: "/users", + method: "get", + module: "middleware", args: { - query, + query }, - withPagination: true, + withPagination: true }); const data = await res.json(); return ( @@ -229,10 +224,9 @@ The pagination object returned from the API has the following structure: ``` - The DataTable component automatically handles pagination controls when - provided with the correct `pageInfo` object, allowing users to navigate - through data with next/previous buttons and showing the current page - information. + The DataTable component automatically handles pagination controls when provided with the correct + `pageInfo` object, allowing users to navigate through data with next/previous buttons and showing + the current page information. ## Advanced Usage @@ -242,11 +236,11 @@ The pagination object returned from the API has the following structure: You can extend the pagination query to include custom filtering: ```ts -const query = c.req.valid('query'); +const query = c.req.valid("query"); const data = await withPagination({ params: { query, - additionalWhere: eq(users.isActive, true), // Only active users + additionalWhere: eq(users.isActive, true) // Only active users }, primaryCursor: users.id, query: async ({ limit, where, orderBy }) => @@ -259,8 +253,8 @@ const data = await withPagination({ table: users, orderBy: { column: query.orderBy ? users[query.orderBy] : users.createdAt, - order: query.order ?? 'desc', - }, + order: query.order ?? "desc" + } }); ``` @@ -269,10 +263,10 @@ const data = await withPagination({ To not block the UI while fetching data, you can use React Suspense: ```tsx title="page.tsx" -import React from 'react'; -import { DataTableSkeleton } from '@vitnode/core/components/table/data-table'; +import React from "react"; +import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { UsersAdminView } from '@/components/UsersAdminView'; +import { UsersAdminView } from "@/components/UsersAdminView"; export default function UsersPage() { return ( diff --git a/apps/docs/content/docs/dev/email/overview.mdx b/apps/docs/content/docs/dev/email/index.mdx similarity index 68% rename from apps/docs/content/docs/dev/email/overview.mdx rename to apps/docs/content/docs/dev/email/index.mdx index 2d2b4d1ca..b63e75607 100644 --- a/apps/docs/content/docs/dev/email/overview.mdx +++ b/apps/docs/content/docs/dev/email/index.mdx @@ -1,5 +1,5 @@ --- -title: Overview +title: Email description: How to use email functionality in your application. --- @@ -19,49 +19,49 @@ or create your own [custom email adapter](/docs/dev/email/overview#custom-email- To send an email, you can use the context `c.get('email').send()` in your route handler. ```ts -import { z } from 'zod'; -import { buildRoute } from '@vitnode/core/api/lib/route'; -import { UserModel } from '@vitnode/core/api/models/user'; +import { z } from "zod"; +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { UserModel } from "@vitnode/core/api/models/user"; export const testRoute = buildRoute({ - handler: async c => { + handler: async (c) => { const user = await new UserModel().getUserById({ id: 3, - c, + c }); - if (!user) throw new Error('User not found'); + if (!user) throw new Error("User not found"); // [!code ++:5] - await c.get('email').send({ - subject: 'Test Email', - content: () => 'This is a test email.', - user, + await c.get("email").send({ + subject: "Test Email", + content: () => "This is a test email.", + user }); - return c.text('test'); - }, + return c.text("test"); + } }); ``` or if you don't want to use `user` then you can just pass `to` field with `locale`: ```ts -import { z } from 'zod'; -import { buildRoute } from '@vitnode/core/api/lib/route'; +import { z } from "zod"; +import { buildRoute } from "@vitnode/core/api/lib/route"; export const testRoute = buildRoute({ - handler: async c => { + handler: async (c) => { // [!code ++:6] - await c.get('email').send({ - to: 'test@test.com', - subject: 'Test Email', - content: () => 'This is a test email.', - locale: 'en', + await c.get("email").send({ + to: "test@test.com", + subject: "Test Email", + content: () => "This is a test email.", + locale: "en" }); - return c.text('test'); - }, + return c.text("test"); + } }); ``` @@ -76,7 +76,7 @@ Want to create your own email adapter? You can do it by implementing the `EmailA Here is your template for a custom email adapter. ```ts title="src/utils/email/mailer.ts" -import type { EmailApiPlugin } from '@vitnode/core/api/models/email'; +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; export const MailerEmailAdapter = (): EmailApiPlugin => {}; ``` @@ -89,16 +89,16 @@ export const MailerEmailAdapter = (): EmailApiPlugin => {}; If you want to provide config for you adapter, you can do it like this: ```ts title="src/utils/email/mailer.ts" -import type { EmailApiPlugin } from '@vitnode/core/api/models/email'; +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; export const MailerEmailAdapter = ({ // [!code ++:13] - host = '', + host = "", port = 587, secure = false, - user = '', - password = '', - from = '', + user = "", + password = "", + from = "" }: { from: string | undefined; host: string | undefined; @@ -118,15 +118,15 @@ export const MailerEmailAdapter = ({ Implement the `sendEmail()` method to send emails using your custom logic. You can use any email sending library or service. ```ts title="src/utils/email/mailer.ts" -import type { EmailApiPlugin } from '@vitnode/core/api/models/email'; +import type { EmailApiPlugin } from "@vitnode/core/api/models/email"; export const MailerEmailAdapter = ({ - host = '', + host = "", port = 587, secure = false, - user = '', - password = '', - from = '', + user = "", + password = "", + from = "" }: { from: string | undefined; host: string | undefined; @@ -137,7 +137,7 @@ export const MailerEmailAdapter = ({ }): EmailApiPlugin => { // [!code ++:3] return { - sendEmail: async ({ metadata, to, subject, html, replyTo, text }) => {}, + sendEmail: async ({ metadata, to, subject, html, replyTo, text }) => {} }; }; ``` diff --git a/apps/docs/content/docs/dev/email/meta.json b/apps/docs/content/docs/dev/email/meta.json index 28bc4adfa..7d2940a6b 100644 --- a/apps/docs/content/docs/dev/email/meta.json +++ b/apps/docs/content/docs/dev/email/meta.json @@ -1,4 +1,4 @@ { "title": "Email", - "pages": ["overview", "templates", "components", "---Adapters---", "..."] + "pages": ["templates", "components", "---Adapters---", "..."] } diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index e288b40e1..86c671ceb 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -13,17 +13,19 @@ "plugins", "api", "database", + "advanced", + "---Adapters---", + "captcha", + "email", + "sso", + "cron", "---Frontend---", "layouts-and-pages", "admin-page", "fetcher", "---UI---", "i18n", - "advanced", - "---Integrations---", - "sso", - "captcha", - "email", + "not-found", "..." ] } diff --git a/apps/docs/content/docs/dev/sso/overview.mdx b/apps/docs/content/docs/dev/sso/index.mdx similarity index 59% rename from apps/docs/content/docs/dev/sso/overview.mdx rename to apps/docs/content/docs/dev/sso/index.mdx index 5472a5bc7..b4e324621 100644 --- a/apps/docs/content/docs/dev/sso/overview.mdx +++ b/apps/docs/content/docs/dev/sso/index.mdx @@ -1,6 +1,6 @@ --- -title: Overview -description: Single Sign-On (SSO) simplifies the user experience by allowing access to multiple systems with a single authentication process. +title: Single Sign-On (SSO) +description: Simplifies the user experience by allowing access to multiple systems with a single authentication process. --- ## How it works @@ -27,7 +27,7 @@ or create your own custom SSO adapter... Want to let your users sign in with their favorite services? Let's build a custom SSO adapter! We'll use Discord as an example, but you can adapt this guide for any OAuth2 provider. -import { Callout } from 'fumadocs-ui/components/callout'; +import { Callout } from "fumadocs-ui/components/callout"; @@ -36,19 +36,19 @@ import { Callout } from 'fumadocs-ui/components/callout'; Let's start with the basics. Create a new file for your SSO provider: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso'; +import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; export const DiscordSSOApiPlugin = ({ clientId, - clientSecret, + clientSecret }: { clientId: string; clientSecret: string; }): SSOApiPlugin => { - const id = 'discord'; + const id = "discord"; const redirectUri = getRedirectUri(id); - return { id, name: 'Discord' }; + return { id, name: "Discord" }; }; ``` @@ -62,46 +62,46 @@ This is like creating a blueprint for your SSO provider. The `id` will be used i Now let's add the magic that sends users to Discord for login: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso'; +import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; export const DiscordSSOApiPlugin = ({ clientId, - clientSecret, + clientSecret }: { clientId: string; clientSecret: string; }): SSOApiPlugin => { - const id = 'discord'; + const id = "discord"; const redirectUri = getRedirectUri(id); return { id, - name: 'Discord', + name: "Discord", // [!code ++] getUrl: ({ state }) => { // [!code ++] - const url = new URL('https://discord.com/oauth2/authorize'); + const url = new URL("https://discord.com/oauth2/authorize"); // [!code ++] - url.searchParams.set('client_id', clientId); + url.searchParams.set("client_id", clientId); // [!code ++] - url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set("redirect_uri", redirectUri); // [!code ++] - url.searchParams.set('response_type', 'code'); + url.searchParams.set("response_type", "code"); // [!code ++] - url.searchParams.set('scope', 'identify email'); + url.searchParams.set("scope", "identify email"); // [!code ++] - url.searchParams.set('state', state); + url.searchParams.set("state", state); // [!code ++] return url.toString(); // [!code ++] - }, + } }; }; ``` - Always include the `state` parameter - it's your security guard against CSRF - attacks. Don't worry, VitNode handles this automatically! + Always include the `state` parameter - it's your security guard against CSRF attacks. Don't worry, + VitNode handles this automatically! @@ -113,41 +113,41 @@ export const DiscordSSOApiPlugin = ({ After the user approves access, Discord sends us a code. Let's exchange it for an access token: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso'; -import { HTTPException } from 'hono/http-exception'; -import { ContentfulStatusCode } from 'hono/utils/http-status'; -import { z } from 'zod'; +import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; +import { HTTPException } from "hono/http-exception"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { z } from "zod"; const tokenSchema = z.object({ access_token: z.string(), - token_type: z.string(), + token_type: z.string() }); export const DiscordSSOApiPlugin = ({ clientId, - clientSecret, + clientSecret }: { clientId: string; clientSecret: string; }): SSOApiPlugin => { - const id = 'discord'; + const id = "discord"; const redirectUri = getRedirectUri(id); return { id, - name: 'Discord', + name: "Discord", // [!code ++] - fetchToken: async code => { + fetchToken: async (code) => { // [!code ++] - const res = await fetch('https://discord.com/api/oauth2/token', { + const res = await fetch("https://discord.com/api/oauth2/token", { // [!code ++] - method: 'POST', + method: "POST", // [!code ++] headers: { // [!code ++] - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", // [!code ++] - Accept: 'application/json', + Accept: "application/json" // [!code ++] }, // [!code ++] @@ -157,13 +157,13 @@ export const DiscordSSOApiPlugin = ({ // [!code ++] redirect_uri: redirectUri, // [!code ++] - grant_type: 'authorization_code', + grant_type: "authorization_code", // [!code ++] client_id: clientId, // [!code ++] - client_secret: clientSecret, + client_secret: clientSecret // [!code ++] - }), + }) // [!code ++] }); @@ -176,9 +176,9 @@ export const DiscordSSOApiPlugin = ({ // [!code ++] { // [!code ++] - message: 'Internal error requesting token', + message: "Internal error requesting token" // [!code ++] - }, + } // [!code ++] ); // [!code ++] @@ -191,7 +191,7 @@ export const DiscordSSOApiPlugin = ({ // [!code ++] throw new HTTPException(400, { // [!code ++] - message: 'Invalid token response', + message: "Invalid token response" // [!code ++] }); // [!code ++] @@ -202,15 +202,15 @@ export const DiscordSSOApiPlugin = ({ // [!code ++] }, getUrl: ({ state }) => { - const url = new URL('https://discord.com/oauth2/authorize'); - url.searchParams.set('client_id', clientId); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', 'identify email'); - url.searchParams.set('state', state); + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify email"); + url.searchParams.set("state", state); return url.toString(); - }, + } }; }; ``` @@ -224,40 +224,40 @@ export const DiscordSSOApiPlugin = ({ Finally, let's get the user's profile data using our shiny new access token: ```ts title="src/utils/sso/discord_api.ts" -import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso'; -import { HTTPException } from 'hono/http-exception'; -import { ContentfulStatusCode } from 'hono/utils/http-status'; -import { z } from 'zod'; +import { SSOApiPlugin, getRedirectUri } from "@vitnode/core/api/models/sso"; +import { HTTPException } from "hono/http-exception"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { z } from "zod"; const userSchema = z.object({ id: z.number(), email: z.string(), - username: z.string(), + username: z.string() }); export const DiscordSSOApiPlugin = ({ clientId, - clientSecret, + clientSecret }: { clientId: string; clientSecret: string; }): SSOApiPlugin => { - const id = 'discord'; + const id = "discord"; const redirectUri = getRedirectUri(id); return { id, - name: 'Discord', + name: "Discord", // [!code ++] fetchUser: async ({ token_type, access_token }) => { // [!code ++] - const res = await fetch('https://discord.com/api/users/@me', { + const res = await fetch("https://discord.com/api/users/@me", { // [!code ++] headers: { // [!code ++] - Authorization: `${token_type} ${access_token}`, + Authorization: `${token_type} ${access_token}` // [!code ++] - }, + } // [!code ++] }); @@ -268,7 +268,7 @@ export const DiscordSSOApiPlugin = ({ // [!code ++] throw new HTTPException(400, { // [!code ++] - message: 'Invalid user response', + message: "Invalid user response" // [!code ++] }); // [!code ++] @@ -278,58 +278,54 @@ export const DiscordSSOApiPlugin = ({ return data; // [!code ++] }, - fetchToken: async code => { - const res = await fetch('https://discord.com/api/oauth2/token', { - method: 'POST', + fetchToken: async (code) => { + const res = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json" }, body: new URLSearchParams({ code, redirect_uri: redirectUri, - grant_type: 'authorization_code', + grant_type: "authorization_code", client_id: clientId, - client_secret: clientSecret, - }), + client_secret: clientSecret + }) }); if (!res.ok) { - throw new HTTPException( - +res.status.toString() as ContentfulStatusCode, - { - message: 'Internal error requesting token', - }, - ); + throw new HTTPException(+res.status.toString() as ContentfulStatusCode, { + message: "Internal error requesting token" + }); } const { data, error } = tokenSchema.safeParse(await res.json()); if (error || !data) { throw new HTTPException(400, { - message: 'Invalid token response', + message: "Invalid token response" }); } return data; }, getUrl: ({ state }) => { - const url = new URL('https://discord.com/oauth2/authorize'); - url.searchParams.set('client_id', clientId); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', 'identify email'); - url.searchParams.set('state', state); + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify email"); + url.searchParams.set("state", state); return url.toString(); - }, + } }; }; ``` - Pro tip: Some OAuth providers might return unverified email addresses. If your - provider gives you an email verification status, add it to your validation to - keep things secure! + Pro tip: Some OAuth providers might return unverified email addresses. If your provider gives you + an email verification status, add it to your validation to keep things secure! @@ -341,12 +337,12 @@ export const DiscordSSOApiPlugin = ({ Last step! Let's plug your new SSO provider into your app: ```ts title="src/app/api/[...route]/route.ts" -import { OpenAPIHono } from '@hono/zod-openapi'; -import { handle } from 'hono/vercel'; -import { VitNodeAPI } from '@vitnode/core/api/config'; -import { DiscordSSOApiPlugin } from '@/utils/sso/discord_api'; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { handle } from "hono/vercel"; +import { VitNodeAPI } from "@vitnode/core/api/config"; +import { DiscordSSOApiPlugin } from "@/utils/sso/discord_api"; -const app = new OpenAPIHono().basePath('/api'); +const app = new OpenAPIHono().basePath("/api"); VitNodeAPI({ app, plugins: [], @@ -358,11 +354,11 @@ VitNodeAPI({ // [!code ++] clientId: process.env.DISCORD_CLIENT_ID, // [!code ++] - clientSecret: process.env.DISCORD_CLIENT_SECRET, + clientSecret: process.env.DISCORD_CLIENT_SECRET // [!code ++] - }), + }) // [!code ++] - ], - }, + ] + } }); ``` diff --git a/apps/docs/content/docs/dev/sso/meta.json b/apps/docs/content/docs/dev/sso/meta.json deleted file mode 100644 index 1e680cafb..000000000 --- a/apps/docs/content/docs/dev/sso/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Single Sign-On (SSO)", - "pages": ["overview", "---Adapters---", "..."] -} diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 821349221..86cbf5f65 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -15,7 +15,7 @@ icon: BookText ### Install package from NPM -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; @@ -38,12 +38,12 @@ npm i @vitnode/blog@canary ### Connect plugin to config ```ts title="src/vitnode.config.ts" -import { blogPlugin } from '@vitnode/blog/config'; +import { blogPlugin } from "@vitnode/blog/config"; ``` ```ts title="src/vitnode.config.ts" export const vitNodeConfig = buildConfig({ - plugins: [blogPlugin()], // [!code highlight] + plugins: [blogPlugin()] // [!code highlight] }); ``` @@ -53,12 +53,12 @@ export const vitNodeConfig = buildConfig({ ### Connect plugin to API config ```ts title="src/vitnode.api.config.ts" -import { blogApiPlugin } from '@vitnode/blog/config.api'; +import { blogApiPlugin } from "@vitnode/blog/config.api"; ``` ```ts title="src/vitnode.api.config.ts" export const vitNodeApiConfig = buildApiConfig({ - plugins: [blogApiPlugin()], // [!code highlight] + plugins: [blogApiPlugin()] // [!code highlight] }); ``` diff --git a/apps/docs/content/docs/ui/tooltip.mdx b/apps/docs/content/docs/ui/tooltip.mdx index 7d23a9b53..a079e1120 100644 --- a/apps/docs/content/docs/ui/tooltip.mdx +++ b/apps/docs/content/docs/ui/tooltip.mdx @@ -10,12 +10,8 @@ description: A tooltip component for displaying additional information on hover ## Usage ```ts -import { Button } from '@vitnode/core/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@vitnode/core/components/ui/tooltip'; +import { Button } from "@vitnode/core/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@vitnode/core/components/ui/tooltip"; ``` ```tsx @@ -29,6 +25,24 @@ import { ``` +### Faster Tooltips + +If you don't need custom props for components like `TooltipTrigger` or `TooltipContent`, you can use the `TooltipWithContent` component to enable faster tooltips. + +```ts +import { Button } from "@vitnode/core/components/ui/button"; +import { TooltipWithContent } from "@vitnode/core/components/ui/tooltip"; +import { PlayIcon } from "lucide-react"; +``` + +```tsx + + + +``` + ## API Reference [Radix UI - Tooltip](https://www.radix-ui.com/primitives/docs/components/tooltip#api-reference) diff --git a/apps/docs/migrations/0000_serious_nightcrawler.sql b/apps/docs/migrations/0000_puzzling_rictor.sql similarity index 96% rename from apps/docs/migrations/0000_serious_nightcrawler.sql rename to apps/docs/migrations/0000_puzzling_rictor.sql index 0cf929755..486b61da3 100644 --- a/apps/docs/migrations/0000_serious_nightcrawler.sql +++ b/apps/docs/migrations/0000_puzzling_rictor.sql @@ -20,6 +20,17 @@ CREATE TABLE "core_admin_sessions" ( ); --> statement-breakpoint ALTER TABLE "core_admin_sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_cron" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "description" varchar(255), + "lastRun" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "pluginId" varchar(100) NOT NULL, + "module" varchar(100) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_cron" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint CREATE TABLE "core_languages" ( "id" serial PRIMARY KEY NOT NULL, "code" varchar(32) NOT NULL, @@ -130,7 +141,8 @@ CREATE TABLE "core_users_confirm_emails" ( "userId" integer NOT NULL, "token" varchar(100) NOT NULL, "createdAt" timestamp DEFAULT now() NOT NULL, - "expires" timestamp NOT NULL, + "expiresAt" timestamp NOT NULL, + "ipAddress" varchar(40) NOT NULL, CONSTRAINT "core_users_confirm_emails_token_unique" UNIQUE("token") ); --> statement-breakpoint @@ -139,7 +151,7 @@ CREATE TABLE "core_users_forgot_password" ( "id" serial PRIMARY KEY NOT NULL, "userId" integer NOT NULL, "token" varchar(100) NOT NULL, - "ip_address" varchar(40) NOT NULL, + "ipAddress" varchar(40) NOT NULL, "createdAt" timestamp DEFAULT now() NOT NULL, "expiresAt" timestamp NOT NULL, CONSTRAINT "core_users_forgot_password_userId_unique" UNIQUE("userId"), diff --git a/apps/docs/migrations/0001_common_spirit.sql b/apps/docs/migrations/0001_common_spirit.sql new file mode 100644 index 000000000..615268237 --- /dev/null +++ b/apps/docs/migrations/0001_common_spirit.sql @@ -0,0 +1 @@ +ALTER TABLE "core_cron" ADD COLUMN "nextRun" timestamp; \ No newline at end of file diff --git a/apps/docs/migrations/0001_reflective_trish_tilby.sql b/apps/docs/migrations/0001_reflective_trish_tilby.sql deleted file mode 100644 index 894bbcbdc..000000000 --- a/apps/docs/migrations/0001_reflective_trish_tilby.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "core_users_confirm_emails" ADD COLUMN "ipAddress" varchar(40) NOT NULL; \ No newline at end of file diff --git a/apps/docs/migrations/0002_first_the_fallen.sql b/apps/docs/migrations/0002_first_the_fallen.sql deleted file mode 100644 index 88a7f1bf8..000000000 --- a/apps/docs/migrations/0002_first_the_fallen.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "core_users_forgot_password" ADD COLUMN "ipAddress" varchar(40) NOT NULL;--> statement-breakpoint -ALTER TABLE "core_users_forgot_password" DROP COLUMN "ip_address"; \ No newline at end of file diff --git a/apps/docs/migrations/0002_public_millenium_guard.sql b/apps/docs/migrations/0002_public_millenium_guard.sql new file mode 100644 index 000000000..32326fcd8 --- /dev/null +++ b/apps/docs/migrations/0002_public_millenium_guard.sql @@ -0,0 +1 @@ +ALTER TABLE "core_cron" ADD COLUMN "schedule" varchar(100) NOT NULL; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0000_snapshot.json b/apps/docs/migrations/meta/0000_snapshot.json index 674182859..9479fbeb3 100644 --- a/apps/docs/migrations/meta/0000_snapshot.json +++ b/apps/docs/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "53f6fec6-1c5b-4344-8806-8d2cfc773fe0", + "id": "b50634b4-ce97-4649-a0e6-49706c351987", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -218,6 +218,62 @@ "checkConstraints": {}, "isRLSEnabled": true }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, "public.core_languages": { "name": "core_languages", "schema": "", @@ -1044,11 +1100,17 @@ "notNull": true, "default": "now()" }, - "expires": { - "name": "expires", + "expiresAt": { + "name": "expiresAt", "type": "timestamp", "primaryKey": false, "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -1097,8 +1159,8 @@ "primaryKey": false, "notNull": true }, - "ip_address": { - "name": "ip_address", + "ipAddress": { + "name": "ipAddress", "type": "varchar(40)", "primaryKey": false, "notNull": true diff --git a/apps/docs/migrations/meta/0001_snapshot.json b/apps/docs/migrations/meta/0001_snapshot.json index 6d9c544d5..ab27abf51 100644 --- a/apps/docs/migrations/meta/0001_snapshot.json +++ b/apps/docs/migrations/meta/0001_snapshot.json @@ -1,6 +1,6 @@ { - "id": "c7112d01-13cf-45e3-a15a-172c4596a436", - "prevId": "53f6fec6-1c5b-4344-8806-8d2cfc773fe0", + "id": "84502ce8-5b3b-4e35-86c7-5ee029e89898", + "prevId": "b50634b4-ce97-4649-a0e6-49706c351987", "version": "7", "dialect": "postgresql", "tables": { @@ -218,6 +218,68 @@ "checkConstraints": {}, "isRLSEnabled": true }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, "public.core_languages": { "name": "core_languages", "schema": "", @@ -1044,8 +1106,8 @@ "notNull": true, "default": "now()" }, - "expires": { - "name": "expires", + "expiresAt": { + "name": "expiresAt", "type": "timestamp", "primaryKey": false, "notNull": true @@ -1103,8 +1165,8 @@ "primaryKey": false, "notNull": true }, - "ip_address": { - "name": "ip_address", + "ipAddress": { + "name": "ipAddress", "type": "varchar(40)", "primaryKey": false, "notNull": true diff --git a/apps/docs/migrations/meta/0002_snapshot.json b/apps/docs/migrations/meta/0002_snapshot.json index 07b31e4e7..99070ee29 100644 --- a/apps/docs/migrations/meta/0002_snapshot.json +++ b/apps/docs/migrations/meta/0002_snapshot.json @@ -1,6 +1,6 @@ { - "id": "e4143cd9-39bd-4bd4-8cd9-e620a0a997b4", - "prevId": "c7112d01-13cf-45e3-a15a-172c4596a436", + "id": "cf42dffe-b907-487c-a3e1-f268f2aff5ec", + "prevId": "84502ce8-5b3b-4e35-86c7-5ee029e89898", "version": "7", "dialect": "postgresql", "tables": { @@ -218,6 +218,74 @@ "checkConstraints": {}, "isRLSEnabled": true }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, "public.core_languages": { "name": "core_languages", "schema": "", @@ -1044,8 +1112,8 @@ "notNull": true, "default": "now()" }, - "expires": { - "name": "expires", + "expiresAt": { + "name": "expiresAt", "type": "timestamp", "primaryKey": false, "notNull": true diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index adee1e171..51c6197d0 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -5,22 +5,22 @@ { "idx": 0, "version": "7", - "when": 1754424977539, - "tag": "0000_serious_nightcrawler", + "when": 1756667224412, + "tag": "0000_puzzling_rictor", "breakpoints": true }, { "idx": 1, "version": "7", - "when": 1754907028203, - "tag": "0001_reflective_trish_tilby", + "when": 1756795000370, + "tag": "0001_common_spirit", "breakpoints": true }, { "idx": 2, "version": "7", - "when": 1754907229544, - "tag": "0002_first_the_fallen", + "when": 1756828546879, + "tag": "0002_public_millenium_guard", "breakpoints": true } ] diff --git a/apps/docs/package.json b/apps/docs/package.json index 566baec4b..5c65499f7 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,7 +4,6 @@ "type": "module", "private": true, "scripts": { - "db:push": "vitnode push", "db:migrate": "vitnode migrate", "docker:dev": "docker compose -f ./docker-compose.yml -p vitnode-dev-dun up -d", "init": "vitnode init", @@ -36,6 +35,7 @@ "motion": "^12.23.12", "next": "^15.5.0", "next-intl": "^4.3.4", + "node-cron": "^4.2.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx index 5c8eabfa7..7c076125f 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx @@ -1,7 +1,9 @@ -import { SignInView } from "@vitnode/core/views/auth/sign-in/sign-in-view"; import type { Metadata } from "next/dist/types"; + import { getTranslations } from "next-intl/server"; +import { SignInView } from "@vitnode/core/views/auth/sign-in/sign-in-view"; + export const generateMetadata = async ({ params, }: { diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx index c96f6fc61..ebb16b44e 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx @@ -1,7 +1,9 @@ -import { PasswordResetView } from "@vitnode/core/views/auth/password-reset/password-reset-view"; import type { Metadata } from "next/dist/types"; + import { getTranslations } from "next-intl/server"; +import { PasswordResetView } from "@vitnode/core/views/auth/password-reset/password-reset-view"; + export const generateMetadata = async ({ params, }: { diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx index 5a3ad9525..999d87e2f 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx @@ -1,7 +1,9 @@ -import { SignUpView } from "@vitnode/core/views/auth/sign-up/sign-up-view"; import type { Metadata } from "next/dist/types"; + import { getTranslations } from "next-intl/server"; +import { SignUpView } from "@vitnode/core/views/auth/sign-up/sign-up-view"; + export const generateMetadata = async ({ params, }: { diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx index 2331c3599..9924bf642 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx @@ -1,5 +1,3 @@ -import { ActionsCategoriesAdmin } from "@vitnode/blog/views/admin/categories/actions/actions"; - import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; import { HeaderContent } from "@vitnode/core/components/ui/header-content"; @@ -8,10 +6,10 @@ import dynamic from "next/dynamic"; import { getTranslations } from "next-intl/server"; import React from "react"; +import { ActionsCategoriesAdmin } from "@vitnode/blog/views/admin/categories/actions/actions"; + const CategoriesAdminView = dynamic(async () => - import( - "@vitnode/blog/views/admin/categories/table/categories-admin-view" - ).then(mod => ({ + import("@vitnode/blog/views/admin/categories/table/categories-admin-view").then(mod => ({ default: mod.CategoriesAdminView, })), ); diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx index 5dbf8a8b3..680ed5f59 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx @@ -1,5 +1,3 @@ -import { ActionsPostsAdmin } from "@vitnode/blog/views/admin/posts/actions/actions"; - import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; import { HeaderContent } from "@vitnode/core/components/ui/header-content"; @@ -8,12 +6,12 @@ import dynamic from "next/dynamic"; import { getTranslations } from "next-intl/server"; import React from "react"; +import { ActionsPostsAdmin } from "@vitnode/blog/views/admin/posts/actions/actions"; + const PostsAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/posts/table/posts-admin-view").then( - mod => ({ - default: mod.PostsAdminView, - }), - ), + import("@vitnode/blog/views/admin/posts/table/posts-admin-view").then(mod => ({ + default: mod.PostsAdminView, + })), ); export const generateMetadata = async (): Promise => { diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx new file mode 100644 index 000000000..52c5eb538 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx @@ -0,0 +1,41 @@ +import dynamic from "next/dynamic"; +import { getTranslations } from "next-intl/server"; +import React from "react"; +import { I18nProvider } from "@vitnode/core/components/i18n-provider"; +import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; +import { HeaderContent } from "@vitnode/core/components/ui/header-content"; + +const CronTableView = dynamic(async () => + import("@vitnode/core/views/admin/views/core/advanced/cron/cron-table-view").then( + module => ({ + default: module.CronTableView, + }), + ), +); + +export const generateMetadata = async () => { + const t = await getTranslations("admin.advanced.cron"); + + return { + title: t("title"), + description: t("desc"), + }; +}; + +export default async function Page( + props: React.ComponentProps, +) { + const t = await getTranslations("admin.advanced.cron"); + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx index 3dd64b364..4513bdad1 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/debug/page.tsx @@ -1,17 +1,18 @@ +import dynamic from "next/dynamic"; +import { getTranslations } from "next-intl/server"; +import React from "react"; + import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; import { HeaderContent } from "@vitnode/core/components/ui/header-content"; import { ClearCacheAction } from "@vitnode/core/views/admin/views/core/debug/actions/clear-cache/clear-cache"; -import dynamic from "next/dynamic"; -import { getTranslations } from "next-intl/server"; -import React from "react"; const SystemLogsView = dynamic(async () => - import( - "@vitnode/core/views/admin/views/core/debug/system-logs/system-logs-view" - ).then(module => ({ - default: module.SystemLogsView, - })), + import("@vitnode/core/views/admin/views/core/debug/system-logs/system-logs-view").then( + module => ({ + default: module.SystemLogsView, + }), + ), ); export const generateMetadata = async () => { diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx index f8d0a61c3..4d8ac4305 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx @@ -1,16 +1,15 @@ -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; import type { Metadata } from "next/dist/types"; import dynamic from "next/dynamic"; import { getTranslations } from "next-intl/server"; import React from "react"; +import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; +import { HeaderContent } from "@vitnode/core/components/ui/header-content"; + const UsersAdminView = dynamic(async () => - import("@vitnode/core/views/admin/views/core/users/users-admin-view").then( - module => ({ - default: module.UsersAdminView, - }), - ), + import("@vitnode/core/views/admin/views/core/users/users-admin-view").then(module => ({ + default: module.UsersAdminView, + })), ); export const generateMetadata = async (): Promise => { diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index cefac4fa4..8eba226a1 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -192,6 +192,36 @@ "home_page": "Home Page", "debug": "Debug Panel", "log_out": "Log Out" + }, + "advanced": { + "title": "Advanced", + "cron": "Cron Jobs" + } + } + }, + "advanced": { + "cron": { + "title": "Cron Jobs", + "desc": "Manage and monitor scheduled tasks.", + "list": { + "name": "Name", + "pluginId": "Plugin ID", + "module": "Module", + "schedule": "Schedule", + "lastRun": { + "title": "Last Run", + "never": "Never" + }, + "nextRun": { + "title": "Next Run", + "never": "Never" + }, + "actions": { + "runNow": { + "label": "Run Job Now", + "success": "Cron job executed successfully." + } + } } } }, diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index 0ad864805..2487f461b 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,4 +1,5 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; +import { NodeCronAdapter } from "@vitnode/core/api/adapters/cron/node-cron.adapter"; import { NodemailerEmailAdapter } from "@vitnode/core/api/adapters/email/nodemailer"; import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord"; import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook"; @@ -25,6 +26,7 @@ export const vitNodeApiConfig = buildApiConfig({ connection: POSTGRES_URL, casing: "camelCase", }), + cronAdapter: NodeCronAdapter(), rateLimiter: { points: 20, // 20 requests duration: 60, // per 60 seconds diff --git a/package.json b/package.json index 7ab14316c..8f54c4a7c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "scripts": { "release": "tsx ./scripts/bump-version.ts", "db:migrate": "turbo db:migrate", - "db:push": "turbo db:push", "docker:dev": "turbo docker:dev", "build:scripts": "turbo build:scripts && pnpm i", "build:plugins": "turbo build:plugins", @@ -21,6 +20,7 @@ "@biomejs/biome": "^2.2.2", "@types/node": "^24.3.0", "@vitnode/config": "workspace:*", + "prettier": "^3.6.2", "tsx": "^4.20.4", "turbo": "^2.5.6", "typescript": "^5.9.2", diff --git a/packages/config/biome.json b/packages/config/biome.json index ede2ca1e9..d7aca419d 100644 --- a/packages/config/biome.json +++ b/packages/config/biome.json @@ -16,7 +16,9 @@ "!build", "!.turbo", "!.source", - "!docker" + "!docker", + "!**/(main)/(plugins)", + "!**/(auth)/(plugins)" ] }, "formatter": { diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/turbo.json b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/turbo.json index ea7c091bc..ce5fd56bd 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/turbo.json +++ b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/turbo.json @@ -9,13 +9,6 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "env": ["POSTGRES_URL"] }, - "db:push": { - "dependsOn": ["^db:push"], - "cache": false, - "persistent": true, - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "env": ["POSTGRES_URL"] - }, "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 9073a03d0..8a6612ff8 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -84,7 +84,6 @@ const rootScripts = ( appName: string, ) => ({ "db:migrate": "turbo db:migrate", - "db:push": "turbo db:push", init: "turbo init", dev: "turbo dev", build: "turbo build", @@ -100,7 +99,6 @@ const apiScripts = ( onlyApi: boolean, appName: string, ) => ({ - "db:push": "vitnode push", "db:migrate": "vitnode migrate", init: "vitnode init --api", ...(pm === "bun" @@ -124,7 +122,6 @@ const singleAppScripts = ( docker: boolean, appName: string, ) => ({ - "db:push": "vitnode push", "db:migrate": "vitnode migrate", init: "vitnode init", dev: "vitnode init && next dev --turbopack", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 45b18dc8f..dadc24024 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -62,6 +62,7 @@ "lucide-react": "^0.540.0", "next": "^15.5.0", "next-intl": "^4.3.4", + "node-cron": "^4.2.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-email": "^4.2.8", @@ -113,6 +114,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cron-parser": "^5.3.1", "input-otp": "^1.4.2", "motion": "^12.23.12", "next-themes": "^0.4.6", diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index 721c119d8..4137168e2 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -1,11 +1,9 @@ /** biome-ignore-all lint/suspicious/noConsole: */ import { count } from "drizzle-orm"; - import { core_admin_permissions } from "@/database/admins.js"; import { core_languages, core_languages_words } from "@/database/languages.js"; import { core_moderators_permissions } from "@/database/moderators.js"; import { core_roles } from "@/database/roles.js"; - import { getConfig } from "./get-config.js"; import { preparePluginsFiles } from "./prepare-plugins-files.js"; import { runInteractiveShellCommand } from "./run-interactive-shell-command.js"; @@ -29,15 +27,6 @@ export const runMigrations = async () => { } }; -export const runPush = async () => { - try { - await runInteractiveShellCommand("npm", ["run", "drizzle-kit", "push"]); - } catch (err) { - console.error("\x1b[31m%s\x1b[0m", err); - process.exit(1); - } -}; - export const initialDataForDatabase = async () => { const config = await getConfig({ type: "api.config" }); const dbClient = config.dbProvider; @@ -134,16 +123,22 @@ export const initialDataForDatabase = async () => { ]); // Insert default permissions - await Promise.all([ - await dbClient.insert(core_moderators_permissions).values({ + await dbClient.insert(core_moderators_permissions).values([ + { roleId: roles[2].id, protected: true, - }), - await dbClient.insert(core_admin_permissions).values({ + }, + { roleId: roles[3].id, protected: true, - }), + }, ]); + + // Insert default admin permissions + await dbClient.insert(core_admin_permissions).values({ + roleId: roles[3].id, + protected: true, + }); } }; @@ -201,18 +196,19 @@ export const prepareDatabase = async ({ ); } - await Promise.all( - steps.map((step, i) => { - const stepNum = `[${i + 1}/${steps.length}]`; - if (step.label === "Insert initial data...") { - console.log(`\n${initMessage} ${stepNum} ${step.label}`); - } else { - console.log(`${initMessage} ${stepNum} ${step.label}`); - } + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const stepNum = `[${i + 1}/${steps.length}]`; - return step.action(); - }), - ); + if (step.label === "Insert initial data...") { + console.log(`\n${initMessage} ${stepNum} ${step.label}`); + } else { + console.log(`${initMessage} ${stepNum} ${step.label}`); + } + + // biome-ignore lint/performance/noAwaitInLoops: This is necessary here. + await step.action(); + } console.log(`${initMessage} \x1b[32mInitial setup completed.\x1b[0m`); process.exit(0); diff --git a/packages/vitnode/scripts/scripts.ts b/packages/vitnode/scripts/scripts.ts index 78e6537b8..f0a02ab49 100644 --- a/packages/vitnode/scripts/scripts.ts +++ b/packages/vitnode/scripts/scripts.ts @@ -9,7 +9,6 @@ import { initialDataForDatabase, prepareDatabase, runMigrations, - runPush, } from "./prepare-database.js"; import { preparePluginsFiles } from "./prepare-plugins-files.js"; @@ -57,14 +56,6 @@ switch (command) { break; - case "push": - await runPush(); - await initialDataForDatabase(); - - console.log(`${initMessage} \x1b[32mDatabase pushed successfully.\x1b[0m`); - process.exit(0); - break; - default: console.log( `${initMessage} \x1b[31mCommand not found: "${command ?? ""}"\x1b[0m`, diff --git a/packages/vitnode/src/api/adapters/cron/node-cron.adapter.ts b/packages/vitnode/src/api/adapters/cron/node-cron.adapter.ts new file mode 100644 index 000000000..24944399f --- /dev/null +++ b/packages/vitnode/src/api/adapters/cron/node-cron.adapter.ts @@ -0,0 +1,12 @@ +import { schedule } from "node-cron"; +import { type CronAdapter, handleCronJobs } from "@/api/lib/cron"; + +export const NodeCronAdapter = (): CronAdapter => { + return { + schedule() { + schedule("*/1 * * * *", async () => { + await handleCronJobs(); + }); + }, + }; +}; diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index feecd8e8a..5e4c41d03 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -4,10 +4,10 @@ import type { Context, Env, Schema } from "hono"; import { cors } from "hono/cors"; import { csrf } from "hono/csrf"; import { HTTPException } from "hono/http-exception"; + import { newBuildPluginApiCore } from "@/api/plugin"; import { CONFIG_PLUGIN } from "@/config"; import type { VitNodeApiConfig } from "@/vitnode.config"; - import { globalAdminMiddleware, globalMiddleware, @@ -62,7 +62,7 @@ export function VitNodeAPI({ authorization: vitNodeApiConfig.authorization, dbProvider: vitNodeApiConfig.dbProvider, captcha: vitNodeApiConfig.captcha, - plugins: vitNodeApiConfig.plugins, + plugins: [newBuildPluginApiCore, ...vitNodeApiConfig.plugins], }), ); app.use(async (c, next) => { @@ -73,16 +73,31 @@ export function VitNodeAPI({ return next(); }); + if (vitNodeApiConfig.cronAdapter) { + vitNodeApiConfig.cronAdapter.schedule(); + } + + [newBuildPluginApiCore, ...vitNodeApiConfig.plugins].map(root => { + app.route(`/${root.pluginId}`, root.hono); + }); + app.onError(async (error, c) => { if (error instanceof HTTPException) { return error.getResponse(); } - await c.get("log").error(`Unhandled error: ${error.message}`); + const errorMessage = error?.message ?? "Unknown error"; + + try { + const logger = c.get("log"); + if (logger) { + await logger.error(`Unhandled error: ${errorMessage}`); + } + } catch {} return new Response( process.env.NODE_ENV === "development" - ? error.message + ? errorMessage : "Internal Server Error", { status: 500, @@ -90,9 +105,5 @@ export function VitNodeAPI({ ); }); - [newBuildPluginApiCore, ...vitNodeApiConfig.plugins].map(root => { - app.route(`/${root.pluginId}`, root.hono); - }); - return app; } diff --git a/packages/vitnode/src/api/lib/cron.ts b/packages/vitnode/src/api/lib/cron.ts new file mode 100644 index 000000000..8b7a9ff6e --- /dev/null +++ b/packages/vitnode/src/api/lib/cron.ts @@ -0,0 +1,44 @@ +import type { Context } from "hono"; +import { CONFIG } from "@/lib/config"; +import type { EnvVitNode } from "../middlewares/global.middleware"; + +export interface CronAdapter { + schedule(): void; +} + +export interface BuildCronReturn { + name: string; + schedule: string; + description?: string; + handler: (c: Context) => void | Promise; +} + +export interface CronJobConfig extends BuildCronReturn { + pluginId: string; + module: string; +} + +export function buildCron({ + name, + schedule, + handler, + description, +}: BuildCronReturn): BuildCronReturn { + return { name, schedule, handler, description }; +} + +export const handleCronJobs = async () => { + const url = new URL("/api/@vitnode/core/cron", CONFIG.api.origin); + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (CONFIG.cronJobSecret) { + headers.authorization = `Bearer ${CONFIG.cronJobSecret}`; + } + + await fetch(url.toString(), { + method: "POST", + headers, + }); +}; diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index de0d2c5a8..80f7a0f45 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -1,5 +1,5 @@ import { OpenAPIHono } from "@hono/zod-openapi"; - +import type { BuildCronReturn } from "./cron"; import type { Route } from "./route"; export interface BuildModuleType { @@ -17,6 +17,7 @@ export interface BaseBuildModuleReturn< name: M; pluginId: P; routes: Routes; + cronJobs: BuildCronReturn[]; } export interface BuildModuleReturn< @@ -38,11 +39,13 @@ export function buildModule< pluginId, name, modules, + cronJobs = [], }: { modules?: Modules; name: M; pluginId: P; routes: Routes; + cronJobs?: BuildCronReturn[]; }): BuildModuleReturn { const hono = new OpenAPIHono(); @@ -58,5 +61,5 @@ export function buildModule< }); } - return { routes, pluginId, hono, name, modules }; + return { routes, pluginId, hono, name, modules, cronJobs }; } diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index f4829a34d..8d495dcc6 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -1,10 +1,12 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { checkPluginId } from "./check-plugin-id"; +import type { CronJobConfig } from "./cron"; import type { BuildModuleReturn } from "./module"; export interface BuildPluginApiReturn { hono: OpenAPIHono; pluginId: string; + cronJobs: Omit[]; } export function buildApiPlugin

({ @@ -18,12 +20,18 @@ export function buildApiPlugin

({ checkPluginId(pluginId); const hono = new OpenAPIHono(); + const cronJobs: BuildPluginApiReturn["cronJobs"] = []; modules.forEach(handler => { hono.route(`/${handler.name}`, handler.hono); + + handler.cronJobs?.forEach(cron => { + cronJobs.push({ ...cron, module: handler.name }); + }); }); return { pluginId, hono, + cronJobs, }; } diff --git a/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts b/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts new file mode 100644 index 000000000..8a098842b --- /dev/null +++ b/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts @@ -0,0 +1,20 @@ +import type { Context, Next } from "hono"; +import { HTTPException } from "hono/http-exception"; + +export const cronAuthMiddleware = () => { + return async (c: Context, next: Next) => { + const cronSecret = c.get("core").cronSecret; + if (!cronSecret) { + throw new HTTPException(403, { message: "Cron access not configured" }); + } + + const authHeader = c.req.header("authorization"); + const providedSecret = authHeader?.replace("Bearer ", ""); + + if (providedSecret !== cronSecret) { + throw new HTTPException(403, { message: "Invalid cron authorization" }); + } + + await next(); + }; +}; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index ec8bf40a1..0fecf6a3e 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -4,7 +4,9 @@ import { HTTPException } from "hono/http-exception"; import { EmailModel, type EmailModelSendArgs } from "@/api/models/email"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; +import { CONFIG } from "@/lib/config"; import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config"; +import type { BuildCronReturn } from "../lib/cron"; import { type LoggerMiddlewareType, loggerMiddleware, @@ -46,6 +48,7 @@ export interface EnvVariablesVitNode { ssoAdapters: SSOApiPlugin[]; }; captcha?: Pick["captcha"]; + cronSecret?: string; email?: VitNodeApiConfig["email"]; metadata: { shortTitle?: string; @@ -53,6 +56,7 @@ export interface EnvVariablesVitNode { }; pathToMessages: (path: string) => Promise<{ default: object }>; plugins: { id: string }[]; + cron: ({ pluginId: string; module: string } & BuildCronReturn)[]; }; db: Pick["dbProvider"]; email: { @@ -95,28 +99,42 @@ export const globalMiddleware = ({ | "plugins" > & Pick) => { + const pluginsMetadata = plugins.map(plugin => ({ + id: plugin.pluginId, + })); + + const cronMetadata = plugins.flatMap(plugin => + plugin.cronJobs.map(cronJob => ({ + pluginId: plugin.pluginId, + module: cronJob.module, + name: cronJob.name, + schedule: cronJob.schedule, + handler: cronJob.handler, + description: cronJob.description, + })), + ); + + const ipHeaderKeys = [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "x-client-ip", + "x-forwarded", + "x-cluster-client-ip", + "forwarded-for", + "forwarded", + "via", + "remote-addr", + "client-ip", + "ip", + "x-ip", + "true-client-ip", + "fastly-client-ip", + "x-fastly-client-ip", + ]; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: return async (c: Context, next: Next) => { - // Collect possible IP header keys in order of trust/preference - const ipHeaderKeys = [ - "x-forwarded-for", - "x-real-ip", - "cf-connecting-ip", - "x-client-ip", - "x-forwarded", - "x-cluster-client-ip", - "forwarded-for", - "forwarded", - "via", - "remote-addr", - "client-ip", - "ip", - "x-ip", - "true-client-ip", - "fastly-client-ip", - "x-fastly-client-ip", - ]; - let ipAddress: string | undefined; // Try to get IP from Hono's request header method first @@ -162,9 +180,9 @@ export const globalMiddleware = ({ cookieSecure: authorization?.cookieSecure ?? true, }, captcha, - plugins: plugins.map(plugin => ({ - id: plugin.pluginId, - })), + cronSecret: CONFIG.cronJobSecret, + plugins: pluginsMetadata, + cron: cronMetadata, }); const user = await new SessionModel(c).getUser(); diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 1593e9822..2015283b8 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -1,13 +1,14 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; - +import { advancedAdminModule } from "./advanced/advanced.admin.module"; import { debugAdminModule } from "./debug/debug.admin.module"; import { sessionAdminRoute } from "./routes/session.route"; import { usersAdminModule } from "./users/users.admin.module"; export const adminModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "admin", routes: [sessionAdminRoute], - modules: [usersAdminModule, debugAdminModule], + modules: [usersAdminModule, debugAdminModule, advancedAdminModule], + cronJobs: [], }); diff --git a/packages/vitnode/src/api/modules/admin/advanced/advanced.admin.module.ts b/packages/vitnode/src/api/modules/admin/advanced/advanced.admin.module.ts new file mode 100644 index 000000000..90dcb5eda --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/advanced/advanced.admin.module.ts @@ -0,0 +1,10 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; +import { cronAdminModule } from "./cron/cron.admin.module"; + +export const advancedAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "advanced", + routes: [], + modules: [cronAdminModule], +}); diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts new file mode 100644 index 000000000..b802a019c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts @@ -0,0 +1,10 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; +import { getCronsRoute } from "./routes/get.route"; +import { runCronRoute } from "./routes/run.route"; + +export const cronAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "cron", + routes: [getCronsRoute, runCronRoute], +}); diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts new file mode 100644 index 000000000..29fd85010 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts @@ -0,0 +1,74 @@ +import z from "zod"; +import { buildRoute } from "@/api/lib/route"; +import { + withPagination, + zodPaginationPageInfo, + zodPaginationQuery, +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_cron } from "@/database/cron"; + +export const getCronsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "Get Admin Cron Logs", + path: "/", + request: { + query: zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(["createdAt", "lastRun", "nextRun"]).optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + edges: z.array( + z.object({ + id: z.number(), + createdAt: z.date(), + name: z.string(), + description: z.string().nullable(), + pluginId: z.string(), + module: z.string(), + lastRun: z.date().nullable(), + nextRun: z.date().nullable(), + schedule: z.string(), + }), + ), + pageInfo: zodPaginationPageInfo, + }), + }, + }, + description: "List of cron jobs", + }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + const data = await withPagination({ + params: { + query, + }, + c, + primaryCursor: core_cron.id, + query: async ({ limit, where, orderBy }) => + await c + .get("db") + .select() + .from(core_cron) + .where(where) + .orderBy(orderBy) + .limit(limit), + table: core_cron, + orderBy: { + column: query.orderBy ? core_cron[query.orderBy] : core_cron.lastRun, + order: query.order ?? "desc", + }, + }); + + return c.json(data); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts new file mode 100644 index 000000000..4ba00453c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts @@ -0,0 +1,113 @@ +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import type { CronJobConfig } from "@/api/lib/cron"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_cron } from "@/database/cron"; +import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date"; + +export const runCronRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Run a specific cron job", + path: "/{id}", + request: { + params: z.object({ + id: z.string(), + }), + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ + message: z.string().optional(), + error: z.string().optional(), + }), + }, + }, + description: "Cron job executed successfully", + }, + 404: { + content: { + "application/json": { + schema: z.object({ + message: z.string().optional(), + error: z.string().optional(), + }), + }, + }, + description: "Cron job not found", + }, + 500: { + content: { + "application/json": { + schema: z.object({ + message: z.string().optional(), + error: z.string().optional(), + }), + }, + }, + description: "Internal server error", + }, + }, + }, + handler: async c => { + const db = c.get("db"); + const cronJobs: CronJobConfig[] = c.get("core").cron; + const param = c.req.valid("param"); + + try { + const cronFromDb = await db + .select() + .from(core_cron) + .where(eq(core_cron.id, +param.id)) + .limit(1); + + if (cronFromDb.length === 0) { + return c.json({ error: "Cron job not found" }, 404); + } + + const dbCron = cronFromDb[0]; + + const job: CronJobConfig | undefined = cronJobs.find( + j => + j.name === dbCron.name && + j.pluginId === dbCron.pluginId && + j.module === dbCron.module, + ); + + if (!job) { + return c.json({ error: "Cron job configuration not found" }, 404); + } + + const now = new Date(); + + try { + await job.handler(c); + + await db + .update(core_cron) + .set({ + lastRun: now, + nextRun: getNextCronRunDate(job.schedule, now), + }) + .where(eq(core_cron.id, dbCron.id)); + + return c.json( + { + message: "Cron job executed successfully", + }, + 201, + ); + } catch (error) { + await c.get("log").error(`Error executing cron job: ${error}`); + return c.json({ error: "Failed to execute cron job" }, 500); + } + } catch (error) { + await c.get("log").error(`Error running cron job: ${error}`); + return c.json({ error: "Internal server error" }, 500); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts index 8df6dbfdb..bbd6a876c 100644 --- a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts @@ -3,7 +3,7 @@ import { buildModule } from "../../../lib/module"; import { logsDebugAdminRoute } from "./routes/logs.route"; export const debugAdminModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "debug", routes: [logsDebugAdminRoute], }); diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts index a97843e18..e5e359a79 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts @@ -13,7 +13,7 @@ import { } from "../../../../lib/with-pagination"; export const logsDebugAdminRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", description: "Get Admin Debug Logs", diff --git a/packages/vitnode/src/api/modules/admin/routes/session.route.ts b/packages/vitnode/src/api/modules/admin/routes/session.route.ts index a6d861f26..0a48349d3 100644 --- a/packages/vitnode/src/api/modules/admin/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/admin/routes/session.route.ts @@ -5,7 +5,7 @@ import { buildRoute } from "@/api/lib/route"; import { CONFIG_PLUGIN } from "@/config"; export const sessionAdminRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", description: "Verify admin session", diff --git a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts index 64af77bce..c431acc57 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts @@ -10,7 +10,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { core_users } from "@/database/users"; export const listUsersAdminRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", description: "Get list of all users", diff --git a/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts index 43472246a..a2e6fe2cc 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts @@ -10,7 +10,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { core_users } from "@/database/users"; export const usersAdminRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", description: "Get list of all users (Admin only)", diff --git a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index 3b31ce8db..60363dd34 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -4,7 +4,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { listUsersAdminRoute } from "./routes/list.route"; export const usersAdminModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "users", routes: [listUsersAdminRoute], }); diff --git a/packages/vitnode/src/api/modules/cron/cron.module.ts b/packages/vitnode/src/api/modules/cron/cron.module.ts new file mode 100644 index 000000000..c17b3b9d2 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/cron.module.ts @@ -0,0 +1,11 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; +import { cleanCron } from "./cron/clean.cron"; +import { runCronRoute } from "./routes/cron.route"; + +export const cronModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "cron", + routes: [runCronRoute], + cronJobs: [cleanCron], +}); diff --git a/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts b/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts new file mode 100644 index 000000000..061b3b5e4 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts @@ -0,0 +1,33 @@ +import { lt } from "drizzle-orm"; +import { buildCron } from "@/api/lib/cron"; +import { core_admin_sessions } from "@/database/admins"; +import { core_sessions } from "@/database/sessions"; +import { core_users_forgot_password } from "@/database/users"; + +export const cleanCron = buildCron({ + name: "clean", + description: "Clean up expired sessions and tokens", + // Run every 1 hour + schedule: "0 * * * *", + handler: async c => { + await c.get("db").transaction(async tx => { + // Delete expired sessions + await tx + .delete(core_sessions) + .where(lt(core_sessions.expiresAt, new Date())); + await tx + .delete(core_admin_sessions) + .where(lt(core_admin_sessions.expiresAt, new Date())); + + // Delete expired forgot password tokens + await tx + .delete(core_users_forgot_password) + .where(lt(core_users_forgot_password.expiresAt, new Date())); + + // // Delete expired email confirmation tokens + // await tx + // .delete(core_users_confirm_emails) + // .where(lt(core_users_confirm_emails.expiresAt, new Date())); + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts b/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts new file mode 100644 index 000000000..385d4bb98 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/helpers/process-cron-jobs.ts @@ -0,0 +1,142 @@ +import { eq, inArray } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { validate } from "node-cron"; +import type { CronJobConfig } from "@/api/lib/cron"; +import { core_cron } from "@/database/cron"; +import { shouldCronJobRun } from "@/lib/api/should-cron-job-run"; + +interface CronJobFromDb { + id: number; + name: string; + description: string | null; + lastRun: Date | null; + nextRun: Date | null; + createdAt: Date; + pluginId: string; + module: string; + schedule: string; +} + +function findExistingJob( + job: CronJobConfig, + cronFromDb: CronJobFromDb[], +): CronJobFromDb | undefined { + return cronFromDb.find( + dbJob => + dbJob.name === job.name && + dbJob.pluginId === job.pluginId && + dbJob.module === job.module, + ); +} + +function getJobChanges( + job: CronJobConfig, + existingJob: CronJobFromDb, +): { description?: boolean; schedule?: boolean } { + const changes: { description?: boolean; schedule?: boolean } = {}; + if (existingJob.description !== job.description) { + changes.description = true; + } + if (existingJob.schedule !== job.schedule) { + changes.schedule = true; + } + return changes; +} + +export async function cleanupOutdatedCronJobs( + db: PostgresJsDatabase>, + cronFromDb: CronJobFromDb[], + currentCronJobs: CronJobConfig[], +) { + if (cronFromDb.length === 0) return; + + const currentCronIdentifiers = currentCronJobs.map( + job => `${job.pluginId}:${job.module}:${job.name}`, + ); + + const cronJobsToDelete = cronFromDb + .filter( + dbCron => + !currentCronIdentifiers.includes( + `${dbCron.pluginId}:${dbCron.module}:${dbCron.name}`, + ), + ) + .map(dbCron => dbCron.id); + + if (cronJobsToDelete.length > 0) { + await db.delete(core_cron).where(inArray(core_cron.id, cronJobsToDelete)); + } +} + +export function processCronJobs( + cronJobs: CronJobConfig[], + cronFromDb: CronJobFromDb[], +) { + const newJobs: CronJobConfig[] = []; + const jobsToExecute: CronJobConfig[] = []; + const jobsToUpdate: { + job: CronJobConfig; + existingJob: CronJobFromDb; + changes: { description?: boolean; schedule?: boolean }; + }[] = []; + + for (const job of cronJobs) { + if (!validate(job.schedule)) { + // biome-ignore lint/suspicious/noConsole: needed for cron job monitoring + console.warn( + `\x1b[34m[VitNode]\x1b[0m \x1b[33mInvalid cron schedule for job "${job.pluginId}:${job.module}:${job.name}"\x1b[0m: ${job.schedule}`, + ); + continue; + } + + const existingJob = findExistingJob(job, cronFromDb); + + if (existingJob) { + const changes = getJobChanges(job, existingJob); + if (Object.keys(changes).length > 0) { + jobsToUpdate.push({ job, existingJob, changes }); + } + } else { + newJobs.push(job); + } + + if (shouldCronJobRun(job.schedule, existingJob?.lastRun || null)) { + jobsToExecute.push(job); + } + } + + return { newJobs, jobsToExecute, jobsToUpdate }; +} + +export async function updateCronJobs( + db: PostgresJsDatabase>, + jobsToUpdate: { + job: CronJobConfig; + existingJob: CronJobFromDb; + changes: { description?: boolean; schedule?: boolean }; + }[], +) { + if (jobsToUpdate.length === 0) return; + + const updatePromises = jobsToUpdate.map(({ job, existingJob, changes }) => { + const updateData: Partial<{ + description: string | null; + schedule: string; + }> = {}; + + if (changes.description) { + updateData.description = job.description || null; + } + + if (changes.schedule) { + updateData.schedule = job.schedule; + } + + return db + .update(core_cron) + .set(updateData) + .where(eq(core_cron.id, existingJob.id)); + }); + + await Promise.all(updatePromises); +} diff --git a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts new file mode 100644 index 000000000..b1a7859f0 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts @@ -0,0 +1,122 @@ +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { buildRoute } from "@/api/lib/route"; +import { cronAuthMiddleware } from "@/api/middlewares/cron-auth.middleware"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_cron } from "@/database/cron"; +import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date"; +import { + cleanupOutdatedCronJobs, + processCronJobs, + updateCronJobs, +} from "../helpers/process-cron-jobs"; + +export const runCronRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Run cron job", + path: "/", + middleware: [cronAuthMiddleware()], + responses: { + 200: { + content: { + "text/plain": { + schema: z.string(), + }, + }, + description: "Cron started successfully", + }, + 403: { + description: "Access Denied", + }, + }, + }, + handler: async c => { + const db = c.get("db"); + const cronJobs = c.get("core").cron; + + try { + const cronFromDb = await db.select().from(core_cron); + await cleanupOutdatedCronJobs(db, cronFromDb, cronJobs); + + const now = new Date(); + let executedJobs = 0; + + const { newJobs, jobsToExecute, jobsToUpdate } = processCronJobs( + cronJobs, + cronFromDb, + ); + + if (newJobs.length > 0) { + try { + const newJobsValues = newJobs.map(job => ({ + name: job.name, + description: job.description || null, + lastRun: null, + nextRun: null, + pluginId: job.pluginId, + module: job.module, + schedule: job.schedule, + })); + + await db.insert(core_cron).values(newJobsValues); + } catch (error) { + await c.get("log").error(`Error inserting new cron jobs: ${error}`); + } + } + + try { + await updateCronJobs(db, jobsToUpdate); + } catch (error) { + await c.get("log").error(`Error updating cron jobs: ${error}`); + } + + if (jobsToExecute.length > 0) { + const executionPromises = jobsToExecute.map(async job => { + try { + await job.handler(c); + + const dbJob = cronFromDb.find( + dbJob => + dbJob.name === job.name && + dbJob.pluginId === job.pluginId && + dbJob.module === job.module, + ); + + if (dbJob) { + await db + .update(core_cron) + .set({ + lastRun: now, + nextRun: getNextCronRunDate(job.schedule, now), + }) + .where(eq(core_cron.id, dbJob.id)); + } + + return { success: true, jobName: job.name }; + } catch (error) { + await c + .get("log") + .error( + `Error executing cron job "${job.pluginId}:${job.module}:${job.name}": ${error}`, + ); + + return { success: false, jobName: job.name, error }; + } + }); + + const results = await Promise.allSettled(executionPromises); + executedJobs = results.filter( + result => result.status === "fulfilled" && result.value.success, + ).length; + } + + return c.text(`Cron jobs processed. Executed ${executedJobs} jobs.`, 200); + } catch (error) { + await c.get("log").error(`Error processing cron jobs: ${error}`); + + return c.text("Error processing cron jobs", 500); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/middleware/middleware.module.ts b/packages/vitnode/src/api/modules/middleware/middleware.module.ts index 0158f4472..cd8240feb 100644 --- a/packages/vitnode/src/api/modules/middleware/middleware.module.ts +++ b/packages/vitnode/src/api/modules/middleware/middleware.module.ts @@ -4,7 +4,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { routeMiddleware } from "./route"; export const middlewareModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "middleware", routes: [routeMiddleware], }); diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts index 202054dd2..1c47669ce 100644 --- a/packages/vitnode/src/api/modules/middleware/route.ts +++ b/packages/vitnode/src/api/modules/middleware/route.ts @@ -15,7 +15,7 @@ export const routeMiddlewareSchema = z.object({ }); export const routeMiddleware = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { path: "/", method: "get", diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts index 532cd0747..829d8ef80 100644 --- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts @@ -16,7 +16,7 @@ export const zodChangePasswordSchema = z.object({ }); export const changePasswordRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Change user password", diff --git a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts index 108b4f7ce..c23676f5a 100644 --- a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts @@ -10,7 +10,7 @@ import ResetPasswordEmailTemplate from "@/emails/reset-password"; import { CONFIG } from "@/lib/config"; export const resetPasswordRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Request a password reset", diff --git a/packages/vitnode/src/api/modules/users/routes/session.route.ts b/packages/vitnode/src/api/modules/users/routes/session.route.ts index e9d0066e2..8a6d9b706 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.ts @@ -5,7 +5,7 @@ import { SessionAdminModel } from "@/api/models/session-admin"; import { CONFIG_PLUGIN } from "@/config"; export const sessionRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", description: "Verify session", diff --git a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts index 6d3c2b681..c08080916 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts @@ -19,7 +19,7 @@ export const zodSignInSchema = z.object({ }); export const signInRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Sign in with email and password", diff --git a/packages/vitnode/src/api/modules/users/routes/sign-out.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-out.route.ts index ef2b46ce4..9ab991c8b 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-out.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-out.route.ts @@ -6,7 +6,7 @@ import { SessionAdminModel } from "@/api/models/session-admin"; import { CONFIG_PLUGIN } from "@/config"; export const signOutRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "delete", description: "Sign out the current admin", diff --git a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts index a1b2f9f7e..165e19d69 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts @@ -27,7 +27,7 @@ export const zodSignUpSchema = z.object({ }); export const signUpRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Create a new user", diff --git a/packages/vitnode/src/api/modules/users/routes/test.route.ts b/packages/vitnode/src/api/modules/users/routes/test.route.ts index 489c74bb3..4dc0ca2a5 100644 --- a/packages/vitnode/src/api/modules/users/routes/test.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/test.route.ts @@ -4,7 +4,7 @@ import { buildRoute } from "@/api/lib/route"; import { CONFIG_PLUGIN } from "@/config"; export const testRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Test route", diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts index ab928594f..1e1c90bab 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts @@ -6,7 +6,7 @@ import { SSOModel } from "@/api/models/sso"; import { CONFIG_PLUGIN } from "@/config"; export const callbackRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", description: "SSO Callback", diff --git a/packages/vitnode/src/api/modules/users/sso/routes/create-url.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/create-url.route.ts index 32deb959a..435e7b733 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/create-url.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/create-url.route.ts @@ -5,7 +5,7 @@ import { SSOModel } from "@/api/models/sso"; import { CONFIG_PLUGIN } from "@/config"; export const createUrlRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Generate SSO URL", diff --git a/packages/vitnode/src/api/modules/users/sso/sso.module.ts b/packages/vitnode/src/api/modules/users/sso/sso.module.ts index dd9df7e54..1329fa1f4 100644 --- a/packages/vitnode/src/api/modules/users/sso/sso.module.ts +++ b/packages/vitnode/src/api/modules/users/sso/sso.module.ts @@ -5,7 +5,7 @@ import { callbackRoute } from "./routes/callback.route"; import { createUrlRoute } from "./routes/create-url.route"; export const ssoUserModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "sso", routes: [callbackRoute, createUrlRoute], }); diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts index b3e4e0223..9c1fc6ec6 100644 --- a/packages/vitnode/src/api/modules/users/users.module.ts +++ b/packages/vitnode/src/api/modules/users/users.module.ts @@ -11,7 +11,7 @@ import { testRoute } from "./routes/test.route"; import { ssoUserModule } from "./sso/sso.module"; export const usersModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "users", routes: [ sessionRoute, diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 70f15100a..12c04d132 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -2,10 +2,11 @@ import { CONFIG_PLUGIN } from "@/config"; import { buildApiPlugin } from "./lib/plugin"; import { adminModule } from "./modules/admin/admin.module"; +import { cronModule } from "./modules/cron/cron.module"; import { middlewareModule } from "./modules/middleware/middleware.module"; import { usersModule } from "./modules/users/users.module"; export const newBuildPluginApiCore = buildApiPlugin({ - ...CONFIG_PLUGIN, - modules: [middlewareModule, usersModule, adminModule], + pluginId: CONFIG_PLUGIN.pluginId, + modules: [middlewareModule, usersModule, adminModule, cronModule], }); diff --git a/packages/vitnode/src/app_admin/core/advanced/cron/page.tsx b/packages/vitnode/src/app_admin/core/advanced/cron/page.tsx new file mode 100644 index 000000000..534e333d6 --- /dev/null +++ b/packages/vitnode/src/app_admin/core/advanced/cron/page.tsx @@ -0,0 +1,41 @@ +import dynamic from "next/dynamic"; +import { getTranslations } from "next-intl/server"; +import React from "react"; +import { I18nProvider } from "@/components/i18n-provider"; +import { DataTableSkeleton } from "@/components/table/data-table"; +import { HeaderContent } from "@/components/ui/header-content"; + +const CronTableView = dynamic(async () => + import("@/views/admin/views/core/advanced/cron/cron-table-view").then( + module => ({ + default: module.CronTableView, + }), + ), +); + +export const generateMetadata = async () => { + const t = await getTranslations("admin.advanced.cron"); + + return { + title: t("title"), + description: t("desc"), + }; +}; + +export default async function Page( + props: React.ComponentProps, +) { + const t = await getTranslations("admin.advanced.cron"); + + return ( + +

+ + + }> + + +
+ + ); +} diff --git a/packages/vitnode/src/components/switchers/langs/language-swietcher.tsx b/packages/vitnode/src/components/switchers/langs/language-switcher.tsx similarity index 100% rename from packages/vitnode/src/components/switchers/langs/language-swietcher.tsx rename to packages/vitnode/src/components/switchers/langs/language-switcher.tsx diff --git a/packages/vitnode/src/components/ui/table.tsx b/packages/vitnode/src/components/ui/table.tsx index 0924e53f5..0303fbfd8 100644 --- a/packages/vitnode/src/components/ui/table.tsx +++ b/packages/vitnode/src/components/ui/table.tsx @@ -80,7 +80,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { return ( [role=checkbox]]:translate-y-[2px]", + "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className, )} data-slot="table-cell" diff --git a/packages/vitnode/src/components/ui/tooltip.tsx b/packages/vitnode/src/components/ui/tooltip.tsx index 6a22d0947..4be9a7ae2 100644 --- a/packages/vitnode/src/components/ui/tooltip.tsx +++ b/packages/vitnode/src/components/ui/tooltip.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; function TooltipProvider({ - delayDuration = 500, + delayDuration = 200, ...props }: React.ComponentProps) { return ( @@ -56,4 +56,28 @@ function TooltipContent({ ); } -export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; +function TooltipWithContent({ + children, + text, + ...props +}: React.ComponentProps & { + text: React.ReactNode; +}) { + return ( + + + {children} + + {text} + + + ); +} + +export { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + TooltipWithContent, +}; diff --git a/packages/vitnode/src/database/cron.ts b/packages/vitnode/src/database/cron.ts new file mode 100644 index 000000000..9f2e6cc2d --- /dev/null +++ b/packages/vitnode/src/database/cron.ts @@ -0,0 +1,13 @@ +import { pgTable } from "drizzle-orm/pg-core"; + +export const core_cron = pgTable("core_cron", t => ({ + id: t.serial().primaryKey(), + name: t.varchar({ length: 255 }).notNull(), + description: t.varchar({ length: 255 }), + lastRun: t.timestamp(), + createdAt: t.timestamp().notNull().defaultNow(), + pluginId: t.varchar({ length: 100 }).notNull(), + module: t.varchar({ length: 100 }).notNull(), + nextRun: t.timestamp(), + schedule: t.varchar({ length: 100 }).notNull(), +})).enableRLS(); diff --git a/packages/vitnode/src/database/users.ts b/packages/vitnode/src/database/users.ts index 00dcb919b..6368b3c6c 100644 --- a/packages/vitnode/src/database/users.ts +++ b/packages/vitnode/src/database/users.ts @@ -99,7 +99,7 @@ export const core_users_confirm_emails = pgTable( .notNull(), token: t.varchar({ length: 100 }).notNull().unique(), createdAt: t.timestamp().notNull().defaultNow(), - expires: t.timestamp().notNull(), + expiresAt: t.timestamp().notNull(), ipAddress: t.varchar({ length: 40 }).notNull(), }), ).enableRLS(); diff --git a/packages/vitnode/src/lib/api/get-next-cron-run-date.test.ts b/packages/vitnode/src/lib/api/get-next-cron-run-date.test.ts new file mode 100644 index 000000000..2036a633e --- /dev/null +++ b/packages/vitnode/src/lib/api/get-next-cron-run-date.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { getNextCronRunDate } from "./get-next-cron-run-date"; + +describe("getNextCronRunDate", () => { + it("should return the next run date for a valid cron schedule with lastRun null", () => { + const schedule = "0 0 * * *"; // Daily at midnight + const lastRun = null; + const result = getNextCronRunDate(schedule, lastRun); + + expect(result).toBeInstanceOf(Date); + if (result) { + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + } + }); + + it("should return the next run date for a valid cron schedule with lastRun provided", () => { + const schedule = "0 0 * * *"; // Daily at midnight + const lastRun = new Date("2023-01-01T12:00:00Z"); + const result = getNextCronRunDate(schedule, lastRun); + + expect(result).toBeInstanceOf(Date); + if (result) { + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + expect(result.getTime()).toBeGreaterThan(lastRun.getTime()); + } + }); + + it("should return null for an invalid cron schedule", () => { + const schedule = "invalid cron"; + const lastRun = null; + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = getNextCronRunDate(schedule, lastRun); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("[VitNode]"), + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it("should handle cron schedules with specific times", () => { + const schedule = "30 14 * * 1"; // Every Monday at 14:30 + const lastRun = new Date("2023-01-01T10:00:00Z"); // Sunday + const result = getNextCronRunDate(schedule, lastRun); + + expect(result).toBeInstanceOf(Date); + if (result) { + expect(result.getDay()).toBe(1); // Monday + expect(result.getHours()).toBe(14); + expect(result.getMinutes()).toBe(30); + } + }); + + it("should return null when cron parsing throws an error", () => { + // This test assumes cron-parser can throw for certain invalid inputs + const schedule = "99 99 99 99 99"; // Invalid cron with out-of-range values + const lastRun = null; + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = getNextCronRunDate(schedule, lastRun); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/vitnode/src/lib/api/get-next-cron-run-date.ts b/packages/vitnode/src/lib/api/get-next-cron-run-date.ts new file mode 100644 index 000000000..e863ac9f0 --- /dev/null +++ b/packages/vitnode/src/lib/api/get-next-cron-run-date.ts @@ -0,0 +1,22 @@ +import cronParser from "cron-parser"; + +export const getNextCronRunDate = ( + schedule: string, + lastRun: Date | null, +): Date | null => { + try { + const options = { + currentDate: lastRun || new Date(0), + }; + + const interval = cronParser.parse(schedule, options); + return interval.next().toDate(); + } catch (err) { + // biome-ignore lint/suspicious/noConsole: needed for cron job monitoring + console.error( + `\x1b[34m[VitNode]\x1b[0m \x1b[38;5;208mError parsing schedule for nextRun\x1b[0m: ${schedule}`, + err, + ); + return null; + } +}; diff --git a/packages/vitnode/src/lib/api/should-cron-job-run.test.ts b/packages/vitnode/src/lib/api/should-cron-job-run.test.ts new file mode 100644 index 000000000..1fa350579 --- /dev/null +++ b/packages/vitnode/src/lib/api/should-cron-job-run.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { shouldCronJobRun } from "./should-cron-job-run"; + +describe("shouldCronJobRun", () => { + beforeEach(() => { + // Reset any mocked console methods + vi.clearAllMocks(); + }); + + describe("valid cron schedules", () => { + it("should return true when job has never run (lastRun is null)", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = null; + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + }); + + it("should return true when enough time has passed since last run", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = new Date("2024-01-01T10:00:00Z"); + + // Mock current time to be 2 hours later + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should return false when not enough time has passed since last run", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = new Date("2024-01-01T11:30:00Z"); + + // Mock current time to be 15 minutes later (not enough for hourly job) + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T11:45:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(false); + + vi.useRealTimers(); + }); + + it("should handle daily job correctly", () => { + const schedule = "0 9 * * *"; // Every day at 9 AM + const lastRun = new Date("2024-01-01T09:00:00Z"); + + // Mock current time to be next day at 10 AM + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-02T10:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle weekly job correctly", () => { + const schedule = "0 9 * * 1"; // Every Monday at 9 AM + const lastRun = new Date("2024-01-01T09:00:00Z"); // Monday + + // Mock current time to be next Monday + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-08T10:00:00Z")); // Next Monday + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle monthly job correctly", () => { + const schedule = "0 9 1 * *"; // First day of every month at 9 AM + const lastRun = new Date("2024-01-01T09:00:00Z"); + + // Mock current time to be next month + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-02-01T10:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle every minute job correctly", () => { + const schedule = "* * * * *"; // Every minute + const lastRun = new Date("2024-01-01T10:00:00Z"); + + // Mock current time to be 1 minute later + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T10:01:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle every 5 minutes job correctly", () => { + const schedule = "*/5 * * * *"; // Every 5 minutes + const lastRun = new Date("2024-01-01T10:00:00Z"); + + // Mock current time to be 3 minutes later (not enough) + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T10:03:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(false); + + // Mock current time to be 5 minutes later (enough) + vi.setSystemTime(new Date("2024-01-01T10:05:00Z")); + + const result2 = shouldCronJobRun(schedule, lastRun); + + expect(result2).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle complex cron expressions", () => { + const schedule = "0 9-17 * * 1-5"; // 9 AM to 5 PM on weekdays + const lastRun = new Date("2024-01-01T09:00:00Z"); // Monday 9 AM + + // Mock current time to be same day at 10 AM + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T10:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + }); + + describe("invalid cron schedules", () => { + it("should return false and log error for invalid cron schedule", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const schedule = "invalid cron"; // Invalid schedule + const lastRun = null; + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("[VitNode]") && + expect.stringContaining("Error parsing schedule") && + expect.stringContaining("invalid cron"), + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it("should return false for malformed cron expression", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const schedule = "60 * * * *"; // Invalid minute (should be 0-59) + const lastRun = null; + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it("should return true for empty cron schedule (parsed as valid)", () => { + // Note: cron-parser actually parses empty string as a valid cron expression + const schedule = ""; // Empty schedule + const lastRun = null; + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should handle lastRun being exactly at the next scheduled time", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = new Date("2024-01-01T10:00:00Z"); + + // Mock current time to be exactly at the next scheduled time + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T11:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle lastRun being in the future (clock skew)", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = new Date("2024-01-01T12:00:00Z"); // Future time + + // Mock current time to be earlier + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T11:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(false); + + vi.useRealTimers(); + }); + + it("should handle lastRun being epoch (new Date(0))", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = new Date(0); // Epoch time + + // Mock current time to be much later + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T10:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle yearly cron job", () => { + const schedule = "0 0 1 1 *"; // January 1st at midnight + const lastRun = new Date("2023-01-01T00:00:00Z"); + + // Mock current time to be next year + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T01:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle specific day of month with month", () => { + const schedule = "0 12 15 6 *"; // June 15th at noon + const lastRun = new Date("2023-06-15T12:00:00Z"); + + // Mock current time to be next year same date + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-06-15T13:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + }); + + describe("timezone handling", () => { + it("should work with different timezone dates", () => { + const schedule = "0 9 * * *"; // Every day at 9 AM + const lastRun = new Date("2024-01-01T09:00:00+05:00"); // 9 AM in +5 timezone + + // Mock current time to be next day in UTC + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-02T04:00:00Z")); // 9 AM in +5 timezone + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + }); + + describe("performance and boundary conditions", () => { + it("should handle very old lastRun dates", () => { + const schedule = "0 * * * *"; // Every hour + const lastRun = new Date("1970-01-01T00:00:00Z"); // Very old date + + // Current time + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T10:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + + it("should handle leap year considerations", () => { + const schedule = "0 0 29 2 *"; // February 29th (leap day) + const lastRun = new Date("2020-02-29T00:00:00Z"); // Last leap year + + // Mock current time to be next leap year + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-02-29T01:00:00Z")); + + const result = shouldCronJobRun(schedule, lastRun); + + expect(result).toBe(true); + + vi.useRealTimers(); + }); + }); +}); diff --git a/packages/vitnode/src/lib/api/should-cron-job-run.ts b/packages/vitnode/src/lib/api/should-cron-job-run.ts new file mode 100644 index 000000000..cc9c5abb2 --- /dev/null +++ b/packages/vitnode/src/lib/api/should-cron-job-run.ts @@ -0,0 +1,25 @@ +import cronParser from "cron-parser"; + +export const shouldCronJobRun = ( + schedule: string, + lastRun: Date | null, +): boolean => { + try { + const now = new Date(); + const options = { + currentDate: lastRun || new Date(0), + }; + + const interval = cronParser.parse(schedule, options); + const nextScheduledRun = interval.next().toDate(); + + return nextScheduledRun <= now; + } catch (err) { + // biome-ignore lint/suspicious/noConsole: needed for cron job monitoring + console.error( + `\x1b[34m[VitNode]\x1b[0m \x1b[38;5;208mError parsing schedule\x1b[0m: ${schedule}`, + err, + ); + return false; + } +}; diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index d1a001cc2..6bffb9458 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -1,14 +1,17 @@ const ENVS = { - api_url: process.env.NEXT_PUBLIC_API_URL, - web_url: process.env.NEXT_PUBLIC_WEB_URL, + apiUrl: process.env.NEXT_PUBLIC_API_URL, + webUrl: process.env.NEXT_PUBLIC_WEB_URL, + cronConfig: + process.env.CRON_SECRET || "default-cron-secret-change-in-production", }; const urls = { - api: new URL(ENVS.api_url ?? "http://localhost:3000"), - web: new URL(ENVS.web_url ?? "http://localhost:3000"), + api: new URL(ENVS.apiUrl ?? "http://localhost:3000"), + web: new URL(ENVS.webUrl ?? "http://localhost:3000"), }; export const CONFIG = { node_development: process.env.NODE_ENV === "development", ...urls, + cronJobSecret: ENVS.cronConfig, }; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index cefac4fa4..8eba226a1 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -192,6 +192,36 @@ "home_page": "Home Page", "debug": "Debug Panel", "log_out": "Log Out" + }, + "advanced": { + "title": "Advanced", + "cron": "Cron Jobs" + } + } + }, + "advanced": { + "cron": { + "title": "Cron Jobs", + "desc": "Manage and monitor scheduled tasks.", + "list": { + "name": "Name", + "pluginId": "Plugin ID", + "module": "Module", + "schedule": "Schedule", + "lastRun": { + "title": "Last Run", + "never": "Never" + }, + "nextRun": { + "title": "Next Run", + "never": "Never" + }, + "actions": { + "runNow": { + "label": "Run Job Now", + "success": "Cron job executed successfully." + } + } } } }, diff --git a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx index ec6384ebd..281ece0b4 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx +++ b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx @@ -9,7 +9,7 @@ import { } from "@/components/ui/sidebar"; import { getSessionAdminApi } from "@/lib/api/get-session-admin-api"; import { I18nProvider } from "../../../components/i18n-provider"; -import { LanguageSwitcher } from "../../../components/switchers/langs/language-swietcher"; +import { LanguageSwitcher } from "../../../components/switchers/langs/language-switcher"; import type { VitNodeConfig } from "../../../vitnode.config"; import type { NavAdminParent } from "./sidebar/nav/nav"; import { SidebarAdmin } from "./sidebar/sidebar"; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx index 17bb32229..74dfc0c53 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item.tsx @@ -67,11 +67,6 @@ export const ItemNavAdmin = ({ { if (isMobile) { @@ -97,11 +92,6 @@ export const ItemNavAdmin = ({ @@ -124,11 +114,6 @@ export const ItemNavAdmin = ({ { if (isMobile) { diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx index e35bc03d9..066555faa 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx @@ -1,4 +1,4 @@ -import { LayoutDashboardIcon, UsersRoundIcon } from "lucide-react"; +import { LayoutDashboardIcon, UsersRoundIcon, WrenchIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { @@ -46,6 +46,17 @@ export const NavSidebarAdmin = async ({ }, ], }, + { + href: "/admin/core/advanced", + title: t("advanced.title"), + icon: , + items: [ + { + title: t("advanced.cron"), + href: "/admin/core/advanced/cron", + }, + ], + }, ], }, ...pluginNav, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx new file mode 100644 index 000000000..b12f4a5e8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-view.tsx @@ -0,0 +1,93 @@ +import { getTranslations } from "next-intl/server"; +import { cronAdminModule } from "@/api/modules/admin/advanced/cron/cron.admin.module"; +import { DateFormat } from "@/components/date-format"; +import { + DataTable, + type SearchParamsDataTable, +} from "@/components/table/data-table"; +import { fetcher } from "@/lib/fetcher"; +import { RunActionCronTable } from "./run-action/run-action"; + +export const CronTableView = async ({ + searchParams, +}: { + searchParams: Promise; +}) => { + const query = await searchParams; + const res = await fetcher(cronAdminModule, { + path: "/", + method: "get", + module: "cron", + prefixPath: "/admin/advanced", + args: { + query, + }, + withPagination: true, + }); + + const [data, t] = await Promise.all([ + res.json(), + getTranslations("admin.advanced.cron"), + ]); + + return ( + ( +
+ {row.name} +

{row.description}

+
+ ), + }, + { id: "pluginId", label: t("list.pluginId") }, + { id: "module", label: t("list.module") }, + { + id: "schedule", + label: t("list.schedule"), + }, + { + id: "lastRun", + label: t("list.lastRun.title"), + cell: ({ row }) => + row.lastRun ? ( + + ) : ( + + {t("list.lastRun.never")} + + ), + }, + { + id: "nextRun", + label: t("list.nextRun.title"), + cell: ({ row }) => + row.nextRun ? ( + + ) : ( + + {t("list.nextRun.never")} + + ), + }, + { + id: "actions", + label: "", + cell: ({ row }) => , + }, + ]} + edges={data.edges} + order={{ + columns: ["lastRun", "createdAt", "nextRun"], + defaultOrder: { + column: "lastRun", + order: "desc", + }, + }} + pageInfo={data.pageInfo} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.ts b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.ts new file mode 100644 index 000000000..9c265b855 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.ts @@ -0,0 +1,25 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cronAdminModule } from "@/api/modules/admin/advanced/cron/cron.admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export const mutationApi = async (id: number) => { + const res = await fetcher(cronAdminModule, { + path: "/{id}", + method: "post", + module: "cron", + prefixPath: "/admin/advanced", + args: { + params: { id: id.toString() }, + }, + }); + + if (!res.ok) { + return { error: "Failed to run cron job" }; + } + + revalidatePath( + "[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron", + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx new file mode 100644 index 000000000..519b9365c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { PlayIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useActionState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { mutationApi } from "./mutation-api"; + +export const RunActionCronTable = ({ id }: { id: number }) => { + const t = useTranslations("admin.advanced.cron.list.actions.runNow"); + const tError = useTranslations("core.global.errors"); + const [_, formAction, isPending] = useActionState(async () => { + const mutation = await mutationApi(id); + if (mutation?.error) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("success")); + }, null); + + return ( +
+ + + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx index e868bcf72..77db94ccb 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx @@ -15,12 +15,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Loader } from "@/components/ui/loader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { TooltipWithContent } from "@/components/ui/tooltip"; const ContentMoreActionSystemLogs = dynamic(async () => import("./content").then(module => ({ @@ -35,18 +30,13 @@ export const MoreActionSystemLogs = ( return ( - - - - - - - - {t("title")} - - + + + + + diff --git a/packages/vitnode/src/views/layouts/theme/header/header.tsx b/packages/vitnode/src/views/layouts/theme/header/header.tsx index 16622c3dd..212447afc 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { LanguageSwitcher } from "@/components/switchers/langs/language-swietcher"; +import { LanguageSwitcher } from "@/components/switchers/langs/language-switcher"; import { ThemeSwitcher } from "@/components/switchers/themes/theme-switcher"; import { Skeleton } from "@/components/ui/skeleton"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index c14f13f7d..d665160f3 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -1,7 +1,7 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { ThemeProvider } from "next-themes"; import type { IRateLimiterOptions } from "rate-limiter-flexible"; - +import type { CronAdapter } from "./api/lib/cron"; import type { BuildPluginApiReturn } from "./api/lib/plugin"; import type { EmailApiPlugin } from "./api/models/email"; import type { SSOApiPlugin } from "./api/models/sso"; @@ -48,6 +48,7 @@ export interface VitNodeApiConfig { type: "cloudflare_turnstile" | "recaptcha_v3"; }; dbProvider: PostgresJsDatabase; + cronAdapter?: CronAdapter; email?: { adapter?: EmailApiPlugin; logo?: DefaultTemplateEmailProps["templateProps"]["logo"]; diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index d3067436f..86aa8b2de 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -5,7 +5,7 @@ import { categoriesAdminModule } from "./categories/categories.admin.module"; import { postsAdminModule } from "./posts/posts.admin.module"; export const adminModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "admin", modules: [categoriesAdminModule, postsAdminModule], routes: [], diff --git a/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts b/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts index 63e17920c..56c107aa0 100644 --- a/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts +++ b/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts @@ -6,7 +6,7 @@ import { deleteCategoryRoute } from "./routes/delete.route"; import { editCategoryRoute } from "./routes/edit.route"; export const categoriesAdminModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "categories", routes: [createCategoryRoute, editCategoryRoute, deleteCategoryRoute], }); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts index 276f43ecd..12f23c3ff 100644 --- a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts +++ b/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts @@ -19,7 +19,7 @@ export const zodCreateCategorySchema = z.object({ }); export const createCategoryRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", path: "/", diff --git a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts index b8a2b1f08..dcc598b81 100644 --- a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts +++ b/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts @@ -7,7 +7,7 @@ import { CONFIG_PLUGIN } from "@/const"; import { blog_categories } from "@/database/categories"; export const deleteCategoryRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "delete", path: "/{id}", diff --git a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts index d4b5cebf3..59b496fc1 100644 --- a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts +++ b/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts @@ -17,7 +17,7 @@ const zodCategoryResponseSchema = z.object({ }); export const editCategoryRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "put", path: "/{id}", diff --git a/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts b/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts index 09f6d203c..c39aa207c 100644 --- a/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts +++ b/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts @@ -6,7 +6,7 @@ import { deletePostRoute } from "./routes/delete.route"; import { editPostRoute } from "./routes/edit.route"; export const postsAdminModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "posts", routes: [editPostRoute, createPostRoute, deletePostRoute], }); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts index 52b2ed376..808ff4f41 100644 --- a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts +++ b/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts @@ -28,7 +28,7 @@ export const zodCreatePostSchema = z.object({ }); export const createPostRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", path: "/", diff --git a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts index 7265023d0..9f528cbe3 100644 --- a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts +++ b/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts @@ -7,7 +7,7 @@ import { CONFIG_PLUGIN } from "@/const"; import { blog_posts } from "@/database/posts"; export const deletePostRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "delete", path: "/{id}", diff --git a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts index 105807b6d..6b3704938 100644 --- a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts +++ b/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts @@ -21,7 +21,7 @@ const zodPostResponseSchema = z.object({ }); export const editPostRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "put", path: "/{id}", diff --git a/plugins/blog/src/api/modules/categories/categories.module.ts b/plugins/blog/src/api/modules/categories/categories.module.ts index 5df3486c1..9a9d80d3f 100644 --- a/plugins/blog/src/api/modules/categories/categories.module.ts +++ b/plugins/blog/src/api/modules/categories/categories.module.ts @@ -6,7 +6,7 @@ import { categoriesRoute } from "./routes/get.route"; import { testRoute } from "./test.route"; export const categoriesModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "categories", routes: [categoriesRoute, testRoute], }); diff --git a/plugins/blog/src/api/modules/categories/routes/get.route.ts b/plugins/blog/src/api/modules/categories/routes/get.route.ts index f0a9bf9de..3d4c3f2e4 100644 --- a/plugins/blog/src/api/modules/categories/routes/get.route.ts +++ b/plugins/blog/src/api/modules/categories/routes/get.route.ts @@ -18,7 +18,7 @@ const zodCategorySchema = z.object({ }); export const categoriesRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", path: "/", diff --git a/plugins/blog/src/api/modules/categories/test.route.ts b/plugins/blog/src/api/modules/categories/test.route.ts index ce48bddd5..d3260901b 100644 --- a/plugins/blog/src/api/modules/categories/test.route.ts +++ b/plugins/blog/src/api/modules/categories/test.route.ts @@ -6,7 +6,7 @@ import { CONFIG_PLUGIN } from "@/const"; import TestTemplateEmail from "@/emails/test-template"; export const testRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "post", description: "Test route", diff --git a/plugins/blog/src/api/modules/posts/posts.module.ts b/plugins/blog/src/api/modules/posts/posts.module.ts index f98c8e660..13e57ab11 100644 --- a/plugins/blog/src/api/modules/posts/posts.module.ts +++ b/plugins/blog/src/api/modules/posts/posts.module.ts @@ -5,7 +5,7 @@ import { CONFIG_PLUGIN } from "@/const"; import { postsRoute } from "./routes/get.route"; export const postsModule = buildModule({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, name: "posts", routes: [postsRoute], }); diff --git a/plugins/blog/src/api/modules/posts/routes/get.route.ts b/plugins/blog/src/api/modules/posts/routes/get.route.ts index 1663bb9c2..2b10e8d2b 100644 --- a/plugins/blog/src/api/modules/posts/routes/get.route.ts +++ b/plugins/blog/src/api/modules/posts/routes/get.route.ts @@ -27,7 +27,7 @@ export const zodPostSchema = z.object({ }); export const postsRoute = buildRoute({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, route: { method: "get", path: "/", diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 008ef6ebe..35072e891 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -8,7 +8,7 @@ import { postsModule } from "./api/modules/posts/posts.module"; export const blogApiPlugin = () => { return buildApiPlugin({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, modules: [adminModule, categoriesModule, postsModule], }); }; diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index cb299b3ec..c0316143c 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -5,7 +5,7 @@ import { CONFIG_PLUGIN } from "@/const"; export const blogPlugin = () => { return buildPlugin({ - ...CONFIG_PLUGIN, + pluginId: CONFIG_PLUGIN.pluginId, admin: { nav: [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd5bae15d..baa13579b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@vitnode/config': specifier: workspace:* version: link:packages/config + prettier: + specifier: ^3.6.2 + version: 3.6.2 tsx: specifier: ^4.20.4 version: 4.20.4 @@ -147,6 +150,9 @@ importers: next-intl: specifier: ^4.3.4 version: 4.3.4(next@15.5.0(@playwright/test@1.55.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + node-cron: + specifier: ^4.2.1 + version: 4.2.1 react: specifier: ^19.1.1 version: 19.1.1 @@ -284,6 +290,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + cron-parser: + specifier: ^5.3.1 + version: 5.3.1 input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -399,6 +408,9 @@ importers: next-intl: specifier: ^4.3.4 version: 4.3.4(next@15.5.0(@babel/core@7.28.3)(@playwright/test@1.55.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + node-cron: + specifier: ^4.2.1 + version: 4.2.1 react: specifier: ^19.1.1 version: 19.1.1 @@ -4388,6 +4400,10 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + cron-parser@5.3.1: + resolution: {integrity: sha512-Mu5Jk1b4cUfY8u34+thI9TZxvQiuhaMBS2Ag84rOSoHlU33xtIPkXwr6lWuw3XPmxSxq317B+hl0o4J+LdhwNg==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5436,6 +5452,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.1: + resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -5836,6 +5856,10 @@ packages: sass: optional: true + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-html-parser@7.0.1: resolution: {integrity: sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==} @@ -11536,6 +11560,10 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cron-parser@5.3.1: + dependencies: + luxon: 3.7.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -12596,6 +12624,8 @@ snapshots: dependencies: react: 19.1.1 + luxon@3.7.1: {} + lz-string@1.5.0: {} magic-string@0.30.17: @@ -13289,6 +13319,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-cron@4.2.1: {} + node-html-parser@7.0.1: dependencies: css-select: 5.2.2 diff --git a/scripts/files/file-copy-manager.ts b/scripts/files/file-copy-manager.ts index 07fb337a8..2c895169a 100644 --- a/scripts/files/file-copy-manager.ts +++ b/scripts/files/file-copy-manager.ts @@ -41,7 +41,6 @@ export class FileCopyManager { "src/app/layout.tsx", "src/app/not-found.tsx", "postcss.config.mjs", - ".prettierrc.mjs", ]); const apiSourcePath = join(this.env.WORKSPACE, "apps", "api"); diff --git a/turbo.json b/turbo.json index ae2aa2dc2..0db974a4e 100644 --- a/turbo.json +++ b/turbo.json @@ -20,13 +20,6 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "env": ["POSTGRES_URL"] }, - "db:push": { - "dependsOn": ["^db:push"], - "cache": false, - "persistent": true, - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "env": ["POSTGRES_URL"] - }, "build:plugins": { "dependsOn": ["^build:plugins"], "outputs": ["dist/**"]