Neo platform - First Version
This commit is contained in:
9
.env.api
Normal file
9
.env.api
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
DATABASE_URL="mysql://platform:platform@db:3306/platform"
|
||||||
|
REDIS_URL="redis://redis:6379"
|
||||||
|
|
||||||
|
# JWT, multi-tenant hints, etc.
|
||||||
|
JWT_SECRET="devsecret"
|
||||||
|
TENANCY_STRATEGY="single-db"
|
||||||
5
.env.web
Normal file
5
.env.web
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
NUXT_PORT=3001
|
||||||
|
NUXT_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Point Nuxt to the API container (not localhost)
|
||||||
|
NUXT_PUBLIC_API_BASE_URL=http://jupiter.routebox.co:3000
|
||||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Production
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/migrations/
|
||||||
|
|
||||||
|
# Nuxt
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
345
GETTING_STARTED.md
Normal file
345
GETTING_STARTED.md
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
# Neo Platform - Getting Started Guide
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- Node.js 22+ (for local development without Docker)
|
||||||
|
|
||||||
|
### Option 1: Using Setup Script (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make setup script executable
|
||||||
|
chmod +x setup.sh
|
||||||
|
|
||||||
|
# Run setup
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Build all Docker containers
|
||||||
|
2. Start all services
|
||||||
|
3. Run database migrations
|
||||||
|
4. Generate Prisma client
|
||||||
|
|
||||||
|
### Option 2: Manual Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to infrastructure directory
|
||||||
|
cd infra
|
||||||
|
|
||||||
|
# Build and start containers
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# Wait for database to be ready (about 10 seconds)
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
docker-compose exec api npx prisma migrate dev --name init
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
docker-compose exec api npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access the Application
|
||||||
|
|
||||||
|
- **Frontend (Web UI)**: http://localhost:3001
|
||||||
|
- **Backend API**: http://localhost:3000/api
|
||||||
|
- **Database**: localhost:3306
|
||||||
|
- User: `platform`
|
||||||
|
- Password: `platform`
|
||||||
|
- Database: `platform`
|
||||||
|
- **Redis**: localhost:6379
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
### 1. Create a Tenant
|
||||||
|
|
||||||
|
Since this is a multi-tenant platform, you need to create a tenant first. Use a tool like cURL or Postman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a tenant directly in the database
|
||||||
|
docker-compose exec db mysql -uplatform -pplatform platform -e "
|
||||||
|
INSERT INTO tenants (id, name, slug, isActive, createdAt, updatedAt)
|
||||||
|
VALUES (UUID(), 'Demo Tenant', 'demo', 1, NOW(), NOW());
|
||||||
|
"
|
||||||
|
|
||||||
|
# Get the tenant ID
|
||||||
|
docker-compose exec db mysql -uplatform -pplatform platform -e "
|
||||||
|
SELECT id, slug FROM tenants WHERE slug='demo';
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the tenant ID for the next steps.
|
||||||
|
|
||||||
|
### 2. Register a User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace TENANT_ID with the actual tenant ID from step 1
|
||||||
|
curl -X POST http://localhost:3000/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-tenant-id: TENANT_ID" \
|
||||||
|
-d '{
|
||||||
|
"email": "admin@demo.com",
|
||||||
|
"password": "password123",
|
||||||
|
"firstName": "Admin",
|
||||||
|
"lastName": "User"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-tenant-id: TENANT_ID" \
|
||||||
|
-d '{
|
||||||
|
"email": "admin@demo.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the `access_token` from the response.
|
||||||
|
|
||||||
|
### 4. Configure Frontend
|
||||||
|
|
||||||
|
In your browser:
|
||||||
|
|
||||||
|
1. Open http://localhost:3001
|
||||||
|
2. Open Developer Tools (F12)
|
||||||
|
3. Go to Console and run:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
localStorage.setItem('tenantId', 'YOUR_TENANT_ID');
|
||||||
|
localStorage.setItem('token', 'YOUR_ACCESS_TOKEN');
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload the page and you should be able to access the platform!
|
||||||
|
|
||||||
|
## Creating Your First App
|
||||||
|
|
||||||
|
### 1. Create an Object Definition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/setup/objects \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-tenant-id: YOUR_TENANT_ID" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"apiName": "Project",
|
||||||
|
"label": "Project",
|
||||||
|
"pluralLabel": "Projects",
|
||||||
|
"description": "Project management object"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create an Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/setup/apps \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-tenant-id: YOUR_TENANT_ID" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"slug": "project-mgmt",
|
||||||
|
"label": "Project Management",
|
||||||
|
"description": "Manage your projects"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create a Page in the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/setup/apps/project-mgmt/pages \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-tenant-id: YOUR_TENANT_ID" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"slug": "projects",
|
||||||
|
"label": "Projects",
|
||||||
|
"type": "list",
|
||||||
|
"objectApiName": "Project",
|
||||||
|
"sortOrder": 0
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Access Your App
|
||||||
|
|
||||||
|
Navigate to: http://localhost:3001/app/project-mgmt/projects
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
|
||||||
|
# All services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker-compose logs -f api
|
||||||
|
docker-compose logs -f web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Prisma Studio (Database GUI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker-compose exec api npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Access at: http://localhost:5555
|
||||||
|
|
||||||
|
### Create a New Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After modifying prisma/schema.prisma
|
||||||
|
cd infra
|
||||||
|
docker-compose exec api npx prisma migrate dev --name your_migration_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker-compose exec api npx prisma migrate reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- `POST /api/auth/register` - Register a new user
|
||||||
|
- `POST /api/auth/login` - Login and get JWT token
|
||||||
|
|
||||||
|
### Runtime APIs (for end users)
|
||||||
|
|
||||||
|
- `GET /api/runtime/apps` - List apps
|
||||||
|
- `GET /api/runtime/apps/:appSlug` - Get app with pages
|
||||||
|
- `GET /api/runtime/apps/:appSlug/pages/:pageSlug` - Get page metadata
|
||||||
|
- `GET /api/runtime/objects/:objectApiName/records` - List records
|
||||||
|
- `GET /api/runtime/objects/:objectApiName/records/:id` - Get record
|
||||||
|
- `POST /api/runtime/objects/:objectApiName/records` - Create record
|
||||||
|
- `PUT /api/runtime/objects/:objectApiName/records/:id` - Update record
|
||||||
|
- `DELETE /api/runtime/objects/:objectApiName/records/:id` - Delete record
|
||||||
|
|
||||||
|
### Setup APIs (for admins)
|
||||||
|
|
||||||
|
- `GET /api/setup/objects` - List object definitions
|
||||||
|
- `GET /api/setup/objects/:objectApiName` - Get object definition
|
||||||
|
- `POST /api/setup/objects` - Create custom object
|
||||||
|
- `POST /api/setup/objects/:objectApiName/fields` - Add field to object
|
||||||
|
- `GET /api/setup/apps` - List apps
|
||||||
|
- `GET /api/setup/apps/:appSlug` - Get app
|
||||||
|
- `POST /api/setup/apps` - Create app
|
||||||
|
- `PUT /api/setup/apps/:appSlug` - Update app
|
||||||
|
- `POST /api/setup/apps/:appSlug/pages` - Create page
|
||||||
|
- `PUT /api/setup/apps/:appSlug/pages/:pageSlug` - Update page
|
||||||
|
|
||||||
|
## Frontend Routes
|
||||||
|
|
||||||
|
- `/` - Home page
|
||||||
|
- `/setup/apps` - App builder (list all apps)
|
||||||
|
- `/setup/apps/:slug` - App detail (manage pages)
|
||||||
|
- `/setup/objects` - Object builder (list all objects)
|
||||||
|
- `/setup/objects/:apiName` - Object detail (manage fields)
|
||||||
|
- `/app/:appSlug/:pageSlug` - Runtime page view
|
||||||
|
- `/app/:appSlug/:pageSlug/:recordId` - Runtime record detail
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
neo/
|
||||||
|
├── backend/ # NestJS API
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── auth/ # JWT authentication
|
||||||
|
│ │ ├── tenant/ # Multi-tenancy middleware
|
||||||
|
│ │ ├── rbac/ # Roles & permissions
|
||||||
|
│ │ ├── object/ # Object & field management
|
||||||
|
│ │ ├── app-builder/ # App & page management
|
||||||
|
│ │ └── prisma/ # Prisma service
|
||||||
|
│ └── prisma/
|
||||||
|
│ └── schema.prisma # Database schema
|
||||||
|
├── frontend/ # Nuxt 3 web app
|
||||||
|
│ ├── pages/ # Vue pages/routes
|
||||||
|
│ ├── composables/ # Reusable composition functions
|
||||||
|
│ └── assets/ # CSS & static files
|
||||||
|
├── infra/
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
├── .env.api # Backend environment variables
|
||||||
|
├── .env.web # Frontend environment variables
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if database is running
|
||||||
|
docker ps | grep platform-db
|
||||||
|
|
||||||
|
# Check database logs
|
||||||
|
cd infra && docker-compose logs db
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Not Starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check API logs
|
||||||
|
cd infra && docker-compose logs api
|
||||||
|
|
||||||
|
# Rebuild API container
|
||||||
|
cd infra && docker-compose up --build api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Not Loading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check web logs
|
||||||
|
cd infra && docker-compose logs web
|
||||||
|
|
||||||
|
# Rebuild web container
|
||||||
|
cd infra && docker-compose up --build web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
If ports 3000, 3001, 3306, or 6379 are already in use, you can modify the port mappings in `infra/docker-compose.yml`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Implement Custom Object Runtime Queries**: Currently only the `Account` object has runtime query support. Extend `ObjectService` to support dynamic queries for custom objects.
|
||||||
|
|
||||||
|
2. **Add Field-Level Security (FLS)**: Implement field-level permissions to control which fields users can read/write.
|
||||||
|
|
||||||
|
3. **Enhance Sharing Rules**: Implement more sophisticated record sharing rules beyond simple ownership.
|
||||||
|
|
||||||
|
4. **Add BullMQ Jobs**: Implement background job processing using BullMQ for async operations.
|
||||||
|
|
||||||
|
5. **Build UI Components**: Use radix-vue to build reusable form components, tables, and modals.
|
||||||
|
|
||||||
|
6. **Add Validation**: Implement proper validation using class-validator for all DTOs.
|
||||||
|
|
||||||
|
7. **Add Tests**: Write unit and integration tests for both backend and frontend.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions, please refer to the README.md or create an issue in the repository.
|
||||||
115
README.md
Normal file
115
README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Multi-Tenant Nova/Salesforce-like Platform
|
||||||
|
|
||||||
|
A multi-tenant application platform where tenants can define custom objects, fields, applications, and pages.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Backend**: NestJS + Fastify + Prisma + Redis + BullMQ
|
||||||
|
- **Frontend**: Nuxt 3 + Vue 3 + Tailwind CSS + shadcn-vue
|
||||||
|
- **Database**: Percona (MySQL-compatible)
|
||||||
|
- **Cache/Queue**: Redis + BullMQ
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
neo/
|
||||||
|
backend/ # NestJS + Prisma + BullMQ
|
||||||
|
src/
|
||||||
|
prisma/
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
Dockerfile
|
||||||
|
frontend/ # Nuxt 3 + Vue 3 + Tailwind + shadcn-vue
|
||||||
|
app/
|
||||||
|
package.json
|
||||||
|
nuxt.config.ts
|
||||||
|
Dockerfile
|
||||||
|
infra/
|
||||||
|
docker-compose.yml
|
||||||
|
.env.api
|
||||||
|
.env.web
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Node.js 22+ (for local development)
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Start all services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start:
|
||||||
|
- API server on http://localhost:3000
|
||||||
|
- Web UI on http://localhost:3001
|
||||||
|
- Percona MySQL on localhost:3306
|
||||||
|
- Redis on localhost:6379
|
||||||
|
|
||||||
|
### Running Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the infra directory
|
||||||
|
docker-compose exec api npx prisma migrate dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Multi-Tenancy
|
||||||
|
- Single database with `tenantId` on all tenant-specific rows
|
||||||
|
- Tenant context via `x-tenant-id` header
|
||||||
|
|
||||||
|
### Object & Field Management
|
||||||
|
- Define custom objects and fields via `/api/setup/objects`
|
||||||
|
- Runtime CRUD operations via `/api/runtime/objects/:objectApiName/records`
|
||||||
|
|
||||||
|
### Application Builder
|
||||||
|
- Create applications with multiple pages
|
||||||
|
- Access apps via `/app/:appSlug/:pageSlug`
|
||||||
|
- Configure apps via `/setup/apps`
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- JWT-based authentication
|
||||||
|
- Role-based access control (RBAC) with Spatie-like permissions
|
||||||
|
- Field-level security (FLS)
|
||||||
|
- Record ownership and sharing rules
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Runtime APIs
|
||||||
|
- `GET /api/runtime/apps` - List apps for current user
|
||||||
|
- `GET /api/runtime/apps/:appSlug` - Get app with pages
|
||||||
|
- `GET /api/runtime/apps/:appSlug/pages/:pageSlug` - Get page metadata
|
||||||
|
- `GET /api/runtime/objects/:objectApiName/records` - List records
|
||||||
|
- `GET /api/runtime/objects/:objectApiName/records/:id` - Get record
|
||||||
|
- `POST /api/runtime/objects/:objectApiName/records` - Create record
|
||||||
|
- `PUT /api/runtime/objects/:objectApiName/records/:id` - Update record
|
||||||
|
- `DELETE /api/runtime/objects/:objectApiName/records/:id` - Delete record
|
||||||
|
|
||||||
|
### Setup APIs
|
||||||
|
- `GET /api/setup/objects` - List object definitions
|
||||||
|
- `POST /api/setup/objects` - Create custom object
|
||||||
|
- `POST /api/setup/objects/:objectApiName/fields` - Add field
|
||||||
|
- `GET /api/setup/apps` - List apps
|
||||||
|
- `POST /api/setup/apps` - Create app
|
||||||
|
- `POST /api/setup/apps/:appSlug/pages` - Add page
|
||||||
|
|
||||||
|
## Frontend Routes
|
||||||
|
|
||||||
|
- `/app/:appSlug` - App home (redirects to first page)
|
||||||
|
- `/app/:appSlug/:pageSlug` - Page view (list/board/dashboard)
|
||||||
|
- `/app/:appSlug/:pageSlug/:recordId` - Record detail view
|
||||||
|
- `/setup/apps` - App builder UI
|
||||||
|
- `/setup/objects` - Object/field builder UI
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
25
backend/.eslintrc.js
Normal file
25
backend/.eslintrc.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
4
backend/.prettierrc
Normal file
4
backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install OpenSSL and other dependencies required by Prisma
|
||||||
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
|
# Install dependencies separately for better caching
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM base AS dev
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
# Install OpenSSL for Prisma
|
||||||
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
|
COPY --from=deps /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Prisma: generate client
|
||||||
|
RUN npx prisma generate || true
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "start:dev"]
|
||||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/package.json
Normal file
82
backend/package.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "neo-backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Multi-tenant Nova/Salesforce-like platform backend",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/platform-fastify": "^10.3.0",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
|
"@prisma/client": "^5.8.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"bullmq": "^5.1.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
"@nestjs/schematics": "^10.1.0",
|
||||||
|
"@nestjs/testing": "^10.3.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.2.4",
|
||||||
|
"prisma": "^5.8.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
224
backend/prisma/migrations/20250101000000_init/migration.sql
Normal file
224
backend/prisma/migrations/20250101000000_init/migration.sql
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `tenants` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`slug` VARCHAR(191) NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `tenants_slug_key`(`slug`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`password` VARCHAR(191) NOT NULL,
|
||||||
|
`firstName` VARCHAR(191) NULL,
|
||||||
|
`lastName` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `users_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `users_tenantId_email_key`(`tenantId`, `email`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `roles` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`guardName` VARCHAR(191) NOT NULL DEFAULT 'api',
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `roles_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `roles_tenantId_name_guardName_key`(`tenantId`, `name`, `guardName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `permissions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`guardName` VARCHAR(191) NOT NULL DEFAULT 'api',
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `permissions_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `permissions_tenantId_name_guardName_key`(`tenantId`, `name`, `guardName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `user_roles` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`roleId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `user_roles_userId_idx`(`userId`),
|
||||||
|
INDEX `user_roles_roleId_idx`(`roleId`),
|
||||||
|
UNIQUE INDEX `user_roles_userId_roleId_key`(`userId`, `roleId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `role_permissions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`roleId` VARCHAR(191) NOT NULL,
|
||||||
|
`permissionId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `role_permissions_roleId_idx`(`roleId`),
|
||||||
|
INDEX `role_permissions_permissionId_idx`(`permissionId`),
|
||||||
|
UNIQUE INDEX `role_permissions_roleId_permissionId_key`(`roleId`, `permissionId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `object_definitions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`apiName` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`pluralLabel` VARCHAR(191) NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`isSystem` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`tableName` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `object_definitions_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `object_definitions_tenantId_apiName_key`(`tenantId`, `apiName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `field_definitions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`objectId` VARCHAR(191) NOT NULL,
|
||||||
|
`apiName` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`isRequired` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isUnique` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isReadonly` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isLookup` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`referenceTo` VARCHAR(191) NULL,
|
||||||
|
`defaultValue` VARCHAR(191) NULL,
|
||||||
|
`options` JSON NULL,
|
||||||
|
`validationRules` JSON NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `field_definitions_objectId_idx`(`objectId`),
|
||||||
|
UNIQUE INDEX `field_definitions_objectId_apiName_key`(`objectId`, `apiName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`status` VARCHAR(191) NOT NULL DEFAULT 'active',
|
||||||
|
`ownerId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `accounts_tenantId_idx`(`tenantId`),
|
||||||
|
INDEX `accounts_ownerId_idx`(`ownerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `apps` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`slug` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`icon` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `apps_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `apps_tenantId_slug_key`(`tenantId`, `slug`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `app_pages` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`appId` VARCHAR(191) NOT NULL,
|
||||||
|
`slug` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL,
|
||||||
|
`objectApiName` VARCHAR(191) NULL,
|
||||||
|
`objectId` VARCHAR(191) NULL,
|
||||||
|
`config` JSON NULL,
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `app_pages_appId_idx`(`appId`),
|
||||||
|
INDEX `app_pages_objectId_idx`(`objectId`),
|
||||||
|
UNIQUE INDEX `app_pages_appId_slug_key`(`appId`, `slug`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `users` ADD CONSTRAINT `users_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `roles` ADD CONSTRAINT `roles_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `permissions` ADD CONSTRAINT `permissions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `permissions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `object_definitions` ADD CONSTRAINT `object_definitions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `field_definitions` ADD CONSTRAINT `field_definitions_objectId_fkey` FOREIGN KEY (`objectId`) REFERENCES `object_definitions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `apps` ADD CONSTRAINT `apps_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `app_pages` ADD CONSTRAINT `app_pages_appId_fkey` FOREIGN KEY (`appId`) REFERENCES `apps`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `app_pages` ADD CONSTRAINT `app_pages_objectId_fkey` FOREIGN KEY (`objectId`) REFERENCES `object_definitions`(`id`) ON UPDATE CASCADE;
|
||||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "mysql"
|
||||||
227
backend/prisma/schema.prisma
Normal file
227
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-tenancy
|
||||||
|
model Tenant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
objectDefinitions ObjectDefinition[]
|
||||||
|
accounts Account[]
|
||||||
|
apps App[]
|
||||||
|
roles Role[]
|
||||||
|
permissions Permission[]
|
||||||
|
|
||||||
|
@@map("tenants")
|
||||||
|
}
|
||||||
|
|
||||||
|
// User & Auth
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
email String
|
||||||
|
password String
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
userRoles UserRole[]
|
||||||
|
accounts Account[]
|
||||||
|
|
||||||
|
@@unique([tenantId, email])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RBAC - Spatie-like
|
||||||
|
model Role {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
guardName String @default("api")
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
userRoles UserRole[]
|
||||||
|
rolePermissions RolePermission[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name, guardName])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Permission {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
guardName String @default("api")
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
rolePermissions RolePermission[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name, guardName])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserRole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
roleId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, roleId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([roleId])
|
||||||
|
@@map("user_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RolePermission {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
roleId String
|
||||||
|
permissionId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roleId, permissionId])
|
||||||
|
@@index([roleId])
|
||||||
|
@@index([permissionId])
|
||||||
|
@@map("role_permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object Definition (Metadata)
|
||||||
|
model ObjectDefinition {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
apiName String
|
||||||
|
label String
|
||||||
|
pluralLabel String?
|
||||||
|
description String? @db.Text
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
tableName String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
fields FieldDefinition[]
|
||||||
|
pages AppPage[]
|
||||||
|
|
||||||
|
@@unique([tenantId, apiName])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("object_definitions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model FieldDefinition {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
objectId String
|
||||||
|
apiName String
|
||||||
|
label String
|
||||||
|
type String // text, number, boolean, date, datetime, lookup, picklist, etc.
|
||||||
|
description String? @db.Text
|
||||||
|
isRequired Boolean @default(false)
|
||||||
|
isUnique Boolean @default(false)
|
||||||
|
isReadonly Boolean @default(false)
|
||||||
|
isLookup Boolean @default(false)
|
||||||
|
referenceTo String? // objectApiName for lookup fields
|
||||||
|
defaultValue String?
|
||||||
|
options Json? // for picklist fields
|
||||||
|
validationRules Json? // custom validation rules
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
object ObjectDefinition @relation(fields: [objectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([objectId, apiName])
|
||||||
|
@@index([objectId])
|
||||||
|
@@map("field_definitions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example static object: Account
|
||||||
|
model Account {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
status String @default("active")
|
||||||
|
ownerId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([ownerId])
|
||||||
|
@@map("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application Builder
|
||||||
|
model App {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
slug String
|
||||||
|
label String
|
||||||
|
description String? @db.Text
|
||||||
|
icon String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
pages AppPage[]
|
||||||
|
|
||||||
|
@@unique([tenantId, slug])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("apps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppPage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
appId String
|
||||||
|
slug String
|
||||||
|
label String
|
||||||
|
type String // list, board, dashboard, form, detail
|
||||||
|
objectApiName String?
|
||||||
|
objectId String?
|
||||||
|
config Json? // page-specific configuration
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
|
||||||
|
object ObjectDefinition? @relation(fields: [objectId], references: [id])
|
||||||
|
|
||||||
|
@@unique([appId, slug])
|
||||||
|
@@index([appId])
|
||||||
|
@@index([objectId])
|
||||||
|
@@map("app_pages")
|
||||||
|
}
|
||||||
11
backend/src/app-builder/app-builder.module.ts
Normal file
11
backend/src/app-builder/app-builder.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppBuilderService } from './app-builder.service';
|
||||||
|
import { RuntimeAppController } from './runtime-app.controller';
|
||||||
|
import { SetupAppController } from './setup-app.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AppBuilderService],
|
||||||
|
controllers: [RuntimeAppController, SetupAppController],
|
||||||
|
exports: [AppBuilderService],
|
||||||
|
})
|
||||||
|
export class AppBuilderModule {}
|
||||||
242
backend/src/app-builder/app-builder.service.ts
Normal file
242
backend/src/app-builder/app-builder.service.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppBuilderService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// Runtime endpoints
|
||||||
|
async getApps(tenantId: string, userId: string) {
|
||||||
|
// For now, return all active apps for the tenant
|
||||||
|
// In production, you'd filter by user permissions
|
||||||
|
return this.prisma.app.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
pages: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { label: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApp(tenantId: string, slug: string, userId: string) {
|
||||||
|
const app = await this.prisma.app.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_slug: {
|
||||||
|
tenantId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
pages: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException(`App ${slug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPage(
|
||||||
|
tenantId: string,
|
||||||
|
appSlug: string,
|
||||||
|
pageSlug: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const app = await this.getApp(tenantId, appSlug, userId);
|
||||||
|
|
||||||
|
const page = await this.prisma.appPage.findFirst({
|
||||||
|
where: {
|
||||||
|
appId: app.id,
|
||||||
|
slug: pageSlug,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
object: {
|
||||||
|
include: {
|
||||||
|
fields: {
|
||||||
|
where: { isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException(`Page ${pageSlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup endpoints
|
||||||
|
async getAllApps(tenantId: string) {
|
||||||
|
return this.prisma.app.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: {
|
||||||
|
pages: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { label: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppForSetup(tenantId: string, slug: string) {
|
||||||
|
const app = await this.prisma.app.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_slug: {
|
||||||
|
tenantId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
pages: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException(`App ${slug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createApp(
|
||||||
|
tenantId: string,
|
||||||
|
data: {
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.prisma.app.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateApp(
|
||||||
|
tenantId: string,
|
||||||
|
slug: string,
|
||||||
|
data: {
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const app = await this.getAppForSetup(tenantId, slug);
|
||||||
|
|
||||||
|
return this.prisma.app.update({
|
||||||
|
where: { id: app.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPage(
|
||||||
|
tenantId: string,
|
||||||
|
appSlug: string,
|
||||||
|
data: {
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
objectApiName?: string;
|
||||||
|
config?: any;
|
||||||
|
sortOrder?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||||
|
|
||||||
|
// If objectApiName is provided, find the object
|
||||||
|
let objectId: string | undefined;
|
||||||
|
if (data.objectApiName) {
|
||||||
|
const obj = await this.prisma.objectDefinition.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_apiName: {
|
||||||
|
tenantId,
|
||||||
|
apiName: data.objectApiName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
objectId = obj?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.appPage.create({
|
||||||
|
data: {
|
||||||
|
appId: app.id,
|
||||||
|
slug: data.slug,
|
||||||
|
label: data.label,
|
||||||
|
type: data.type,
|
||||||
|
objectApiName: data.objectApiName,
|
||||||
|
objectId,
|
||||||
|
config: data.config,
|
||||||
|
sortOrder: data.sortOrder || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePage(
|
||||||
|
tenantId: string,
|
||||||
|
appSlug: string,
|
||||||
|
pageSlug: string,
|
||||||
|
data: {
|
||||||
|
label?: string;
|
||||||
|
type?: string;
|
||||||
|
objectApiName?: string;
|
||||||
|
config?: any;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||||
|
|
||||||
|
const page = await this.prisma.appPage.findFirst({
|
||||||
|
where: {
|
||||||
|
appId: app.id,
|
||||||
|
slug: pageSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException(`Page ${pageSlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If objectApiName is provided, find the object
|
||||||
|
let objectId: string | undefined;
|
||||||
|
if (data.objectApiName) {
|
||||||
|
const obj = await this.prisma.objectDefinition.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_apiName: {
|
||||||
|
tenantId,
|
||||||
|
apiName: data.objectApiName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
objectId = obj?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.appPage.update({
|
||||||
|
where: { id: page.id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
objectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/app-builder/runtime-app.controller.ts
Normal file
45
backend/src/app-builder/runtime-app.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AppBuilderService } from './app-builder.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('runtime/apps')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class RuntimeAppController {
|
||||||
|
constructor(private appBuilderService: AppBuilderService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getApps(@TenantId() tenantId: string, @CurrentUser() user: any) {
|
||||||
|
return this.appBuilderService.getApps(tenantId, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':appSlug')
|
||||||
|
async getApp(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('appSlug') appSlug: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.appBuilderService.getApp(tenantId, appSlug, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':appSlug/pages/:pageSlug')
|
||||||
|
async getPage(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('appSlug') appSlug: string,
|
||||||
|
@Param('pageSlug') pageSlug: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.appBuilderService.getPage(
|
||||||
|
tenantId,
|
||||||
|
appSlug,
|
||||||
|
pageSlug,
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
backend/src/app-builder/setup-app.controller.ts
Normal file
69
backend/src/app-builder/setup-app.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AppBuilderService } from './app-builder.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('setup/apps')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class SetupAppController {
|
||||||
|
constructor(private appBuilderService: AppBuilderService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getAllApps(@TenantId() tenantId: string) {
|
||||||
|
return this.appBuilderService.getAllApps(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':appSlug')
|
||||||
|
async getApp(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('appSlug') appSlug: string,
|
||||||
|
) {
|
||||||
|
return this.appBuilderService.getAppForSetup(tenantId, appSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createApp(@TenantId() tenantId: string, @Body() data: any) {
|
||||||
|
return this.appBuilderService.createApp(tenantId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':appSlug')
|
||||||
|
async updateApp(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('appSlug') appSlug: string,
|
||||||
|
@Body() data: any,
|
||||||
|
) {
|
||||||
|
return this.appBuilderService.updateApp(tenantId, appSlug, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':appSlug/pages')
|
||||||
|
async createPage(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('appSlug') appSlug: string,
|
||||||
|
@Body() data: any,
|
||||||
|
) {
|
||||||
|
return this.appBuilderService.createPage(tenantId, appSlug, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':appSlug/pages/:pageSlug')
|
||||||
|
async updatePage(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('appSlug') appSlug: string,
|
||||||
|
@Param('pageSlug') pageSlug: string,
|
||||||
|
@Body() data: any,
|
||||||
|
) {
|
||||||
|
return this.appBuilderService.updatePage(
|
||||||
|
tenantId,
|
||||||
|
appSlug,
|
||||||
|
pageSlug,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/app.module.ts
Normal file
23
backend/src/app.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
|
import { ObjectModule } from './object/object.module';
|
||||||
|
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
TenantModule,
|
||||||
|
AuthModule,
|
||||||
|
RbacModule,
|
||||||
|
ObjectModule,
|
||||||
|
AppBuilderModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
82
backend/src/auth/auth.controller.ts
Normal file
82
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UnauthorizedException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RegisterDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('login')
|
||||||
|
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new UnauthorizedException('Tenant ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.authService.validateUser(
|
||||||
|
tenantId,
|
||||||
|
loginDto.email,
|
||||||
|
loginDto.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authService.login(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
async register(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() registerDto: RegisterDto,
|
||||||
|
) {
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new UnauthorizedException('Tenant ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.authService.register(
|
||||||
|
tenantId,
|
||||||
|
registerDto.email,
|
||||||
|
registerDto.password,
|
||||||
|
registerDto.firstName,
|
||||||
|
registerDto.lastName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/auth/auth.module.ts
Normal file
24
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('JWT_SECRET', 'devsecret'),
|
||||||
|
signOptions: { expiresIn: '24h' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
controllers: [AuthController],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
92
backend/src/auth/auth.service.ts
Normal file
92
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(
|
||||||
|
tenantId: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_email: {
|
||||||
|
tenantId,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tenant: true,
|
||||||
|
userRoles: {
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
include: {
|
||||||
|
rolePermissions: {
|
||||||
|
include: {
|
||||||
|
permission: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && (await bcrypt.compare(password, user.password))) {
|
||||||
|
const { password, ...result } = user;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(user: any) {
|
||||||
|
const payload = {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: this.jwtService.sign(payload),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(
|
||||||
|
tenantId: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
firstName?: string,
|
||||||
|
lastName?: string,
|
||||||
|
) {
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { password: _, ...result } = user;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/auth/current-user.decorator.ts
Normal file
8
backend/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
5
backend/src/auth/jwt-auth.guard.ts
Normal file
5
backend/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
30
backend/src/auth/jwt.strategy.ts
Normal file
30
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Strategy } from 'passport-jwt';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: (request: FastifyRequest) => {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
return authHeader.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('JWT_SECRET', 'devsecret'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
tenantId: payload.tenantId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/main.ts
Normal file
39
backend/src/main.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import {
|
||||||
|
FastifyAdapter,
|
||||||
|
NestFastifyApplication,
|
||||||
|
} from '@nestjs/platform-fastify';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global validation pipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
|
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
11
backend/src/object/object.module.ts
Normal file
11
backend/src/object/object.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ObjectService } from './object.service';
|
||||||
|
import { RuntimeObjectController } from './runtime-object.controller';
|
||||||
|
import { SetupObjectController } from './setup-object.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ObjectService],
|
||||||
|
controllers: [RuntimeObjectController, SetupObjectController],
|
||||||
|
exports: [ObjectService],
|
||||||
|
})
|
||||||
|
export class ObjectModule {}
|
||||||
191
backend/src/object/object.service.ts
Normal file
191
backend/src/object/object.service.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ObjectService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// Setup endpoints - Object metadata management
|
||||||
|
async getObjectDefinitions(tenantId: string) {
|
||||||
|
return this.prisma.objectDefinition.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: {
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
orderBy: { label: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectDefinition(tenantId: string, apiName: string) {
|
||||||
|
const obj = await this.prisma.objectDefinition.findUnique({
|
||||||
|
where: {
|
||||||
|
tenantId_apiName: {
|
||||||
|
tenantId,
|
||||||
|
apiName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
fields: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { label: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!obj) {
|
||||||
|
throw new NotFoundException(`Object ${apiName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createObjectDefinition(
|
||||||
|
tenantId: string,
|
||||||
|
data: {
|
||||||
|
apiName: string;
|
||||||
|
label: string;
|
||||||
|
pluralLabel?: string;
|
||||||
|
description?: string;
|
||||||
|
isSystem?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.prisma.objectDefinition.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
tableName: `custom_${data.apiName.toLowerCase()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFieldDefinition(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
data: {
|
||||||
|
apiName: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
description?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
isUnique?: boolean;
|
||||||
|
isLookup?: boolean;
|
||||||
|
referenceTo?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
options?: any;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const obj = await this.getObjectDefinition(tenantId, objectApiName);
|
||||||
|
|
||||||
|
return this.prisma.fieldDefinition.create({
|
||||||
|
data: {
|
||||||
|
objectId: obj.id,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime endpoints - CRUD operations
|
||||||
|
async getRecords(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
filters?: any,
|
||||||
|
) {
|
||||||
|
// For demonstration, using Account as example static object
|
||||||
|
if (objectApiName === 'Account') {
|
||||||
|
return this.prisma.account.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
ownerId: userId, // Basic sharing rule
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For custom objects, you'd need dynamic query building
|
||||||
|
// This is a simplified version
|
||||||
|
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
recordId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
if (objectApiName === 'Account') {
|
||||||
|
const record = await this.prisma.account.findFirst({
|
||||||
|
where: {
|
||||||
|
id: recordId,
|
||||||
|
tenantId,
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new NotFoundException('Record not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
data: any,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
if (objectApiName === 'Account') {
|
||||||
|
return this.prisma.account.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ownerId: userId,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
recordId: string,
|
||||||
|
data: any,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
if (objectApiName === 'Account') {
|
||||||
|
// Verify ownership
|
||||||
|
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||||
|
|
||||||
|
return this.prisma.account.update({
|
||||||
|
where: { id: recordId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
recordId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
if (objectApiName === 'Account') {
|
||||||
|
// Verify ownership
|
||||||
|
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||||
|
|
||||||
|
return this.prisma.account.delete({
|
||||||
|
where: { id: recordId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/src/object/runtime-object.controller.ts
Normal file
98
backend/src/object/runtime-object.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ObjectService } from './object.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('runtime/objects')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class RuntimeObjectController {
|
||||||
|
constructor(private objectService: ObjectService) {}
|
||||||
|
|
||||||
|
@Get(':objectApiName/records')
|
||||||
|
async getRecords(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Query() query: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.getRecords(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
user.userId,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':objectApiName/records/:id')
|
||||||
|
async getRecord(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.getRecord(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
id,
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':objectApiName/records')
|
||||||
|
async createRecord(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Body() data: any,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.createRecord(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
data,
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':objectApiName/records/:id')
|
||||||
|
async updateRecord(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: any,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.updateRecord(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':objectApiName/records/:id')
|
||||||
|
async deleteRecord(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.deleteRecord(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
id,
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/object/setup-object.controller.ts
Normal file
51
backend/src/object/setup-object.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ObjectService } from './object.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('setup/objects')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class SetupObjectController {
|
||||||
|
constructor(private objectService: ObjectService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getObjectDefinitions(@TenantId() tenantId: string) {
|
||||||
|
return this.objectService.getObjectDefinitions(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':objectApiName')
|
||||||
|
async getObjectDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
) {
|
||||||
|
return this.objectService.getObjectDefinition(tenantId, objectApiName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createObjectDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() data: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.createObjectDefinition(tenantId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':objectApiName/fields')
|
||||||
|
async createFieldDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Body() data: any,
|
||||||
|
) {
|
||||||
|
return this.objectService.createFieldDefinition(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
16
backend/src/prisma/prisma.service.ts
Normal file
16
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/src/rbac/permissions.guard.ts
Normal file
35
backend/src/rbac/permissions.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { RbacService } from './rbac.service';
|
||||||
|
import { PERMISSIONS_KEY } from './rbac.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private rbacService: RbacService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||||
|
PERMISSIONS_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredPermissions) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.rbacService.hasAllPermissions(
|
||||||
|
user.userId,
|
||||||
|
requiredPermissions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/rbac/rbac.decorator.ts
Normal file
8
backend/src/rbac/rbac.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const PERMISSIONS_KEY = 'permissions';
|
||||||
|
export const Permissions = (...permissions: string[]) =>
|
||||||
|
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
8
backend/src/rbac/rbac.module.ts
Normal file
8
backend/src/rbac/rbac.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RbacService } from './rbac.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [RbacService],
|
||||||
|
exports: [RbacService],
|
||||||
|
})
|
||||||
|
export class RbacModule {}
|
||||||
103
backend/src/rbac/rbac.service.ts
Normal file
103
backend/src/rbac/rbac.service.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RbacService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getUserPermissions(userId: string): Promise<string[]> {
|
||||||
|
const userRoles = await this.prisma.userRole.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
include: {
|
||||||
|
rolePermissions: {
|
||||||
|
include: {
|
||||||
|
permission: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissions = new Set<string>();
|
||||||
|
userRoles.forEach((userRole) => {
|
||||||
|
userRole.role.rolePermissions.forEach((rp) => {
|
||||||
|
permissions.add(rp.permission.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserRoles(userId: string): Promise<string[]> {
|
||||||
|
const userRoles = await this.prisma.userRole.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return userRoles.map((ur) => ur.role.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasPermission(userId: string, permission: string): Promise<boolean> {
|
||||||
|
const permissions = await this.getUserPermissions(userId);
|
||||||
|
return permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasRole(userId: string, role: string): Promise<boolean> {
|
||||||
|
const roles = await this.getUserRoles(userId);
|
||||||
|
return roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasAnyPermission(
|
||||||
|
userId: string,
|
||||||
|
permissions: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const userPermissions = await this.getUserPermissions(userId);
|
||||||
|
return permissions.some((p) => userPermissions.includes(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasAllPermissions(
|
||||||
|
userId: string,
|
||||||
|
permissions: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const userPermissions = await this.getUserPermissions(userId);
|
||||||
|
return permissions.every((p) => userPermissions.includes(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignRole(userId: string, roleId: string) {
|
||||||
|
return this.prisma.userRole.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRole(userId: string, roleId: string) {
|
||||||
|
return this.prisma.userRole.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncPermissionsToRole(roleId: string, permissionIds: string[]) {
|
||||||
|
// Remove existing permissions
|
||||||
|
await this.prisma.rolePermission.deleteMany({
|
||||||
|
where: { roleId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new permissions
|
||||||
|
await this.prisma.rolePermission.createMany({
|
||||||
|
data: permissionIds.map((permissionId) => ({
|
||||||
|
roleId,
|
||||||
|
permissionId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/rbac/roles.guard.ts
Normal file
33
backend/src/rbac/roles.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { RbacService } from './rbac.service';
|
||||||
|
import { ROLES_KEY } from './rbac.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private rbacService: RbacService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||||
|
ROLES_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoles = await this.rbacService.getUserRoles(user.userId);
|
||||||
|
return requiredRoles.some((role) => userRoles.includes(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/tenant/tenant.decorator.ts
Normal file
8
backend/src/tenant/tenant.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const TenantId = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.raw.tenantId;
|
||||||
|
},
|
||||||
|
);
|
||||||
16
backend/src/tenant/tenant.middleware.ts
Normal file
16
backend/src/tenant/tenant.middleware.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
|
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
// Attach tenantId to request object
|
||||||
|
(req as any).tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/tenant/tenant.module.ts
Normal file
9
backend/src/tenant/tenant.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||||
|
import { TenantMiddleware } from './tenant.middleware';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class TenantModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/tsconfig.json
Normal file
22
backend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM base AS dev
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
COPY --from=deps /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3001"]
|
||||||
9
frontend/app.vue
Normal file
9
frontend/app.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import 'assets/css/main.css';
|
||||||
|
</style>
|
||||||
55
frontend/assets/css/main.css
Normal file
55
frontend/assets/css/main.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/composables/useApi.ts
Normal file
66
frontend/composables/useApi.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export const useApi = () => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const apiBaseUrl = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tenant ID from localStorage or state
|
||||||
|
if (process.client) {
|
||||||
|
const tenantId = localStorage.getItem('tenantId')
|
||||||
|
if (tenantId) {
|
||||||
|
headers['x-tenant-id'] = tenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async get(path: string) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
||||||
|
headers: getHeaders(),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async post(path: string, data: any) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async put(path: string, data: any) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(path: string) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders(),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { api }
|
||||||
|
}
|
||||||
32
frontend/nuxt.config.ts
Normal file
32
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: ['@nuxtjs/tailwindcss', '@nuxtjs/color-mode'],
|
||||||
|
|
||||||
|
colorMode: {
|
||||||
|
classSuffix: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'Neo Platform',
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
compatibilityDate: '2024-01-01',
|
||||||
|
})
|
||||||
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "neo-frontend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Multi-tenant Nova/Salesforce-like platform frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.11.4",
|
||||||
|
"@vueuse/core": "^10.7.2",
|
||||||
|
"nuxt": "^3.10.0",
|
||||||
|
"vue": "^3.4.15",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"radix-vue": "^1.4.1",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.1",
|
||||||
|
"lucide-vue-next": "^0.309.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxtjs/color-mode": "^3.3.2",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
frontend/pages/app/[appSlug]/[pageSlug].vue
Normal file
87
frontend/pages/app/[appSlug]/[pageSlug].vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="page in pages"
|
||||||
|
:key="page.id"
|
||||||
|
:to="`/app/${appSlug}/${page.slug}`"
|
||||||
|
class="text-sm hover:text-primary"
|
||||||
|
>
|
||||||
|
{{ page.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else-if="page">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">{{ page.label }}</h1>
|
||||||
|
<p class="text-muted-foreground mb-4">Page Type: {{ page.type }}</p>
|
||||||
|
|
||||||
|
<div v-if="page.objectApiName">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">{{ page.object?.label }} Records</h2>
|
||||||
|
|
||||||
|
<div v-if="loadingRecords" class="text-center py-8">Loading records...</div>
|
||||||
|
<div v-else-if="records.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No records found
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.id"
|
||||||
|
:to="`/app/${appSlug}/${pageSlug}/${record.id}`"
|
||||||
|
class="block p-4 border rounded-lg hover:border-primary transition-colors bg-card"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ record.name || record.id }}</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const appSlug = computed(() => route.params.appSlug as string)
|
||||||
|
const pageSlug = computed(() => route.params.pageSlug as string)
|
||||||
|
|
||||||
|
const pages = ref([])
|
||||||
|
const page = ref(null)
|
||||||
|
const records = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadingRecords = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const fetchPage = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
page.value = await api.get(`/runtime/apps/${appSlug.value}/pages/${pageSlug.value}`)
|
||||||
|
|
||||||
|
const app = await api.get(`/runtime/apps/${appSlug.value}`)
|
||||||
|
pages.value = app.pages
|
||||||
|
|
||||||
|
if (page.value.objectApiName) {
|
||||||
|
loadingRecords.value = true
|
||||||
|
records.value = await api.get(`/runtime/objects/${page.value.objectApiName}/records`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loadingRecords.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPage()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
67
frontend/pages/app/[appSlug]/[pageSlug]/[recordId].vue
Normal file
67
frontend/pages/app/[appSlug]/[pageSlug]/[recordId].vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else-if="record">
|
||||||
|
<div class="mb-6">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/app/${appSlug}/${pageSlug}`"
|
||||||
|
class="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
← Back to List
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold mb-6">{{ record.name || 'Record Detail' }}</h1>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="(value, key) in record" :key="key" class="border-b pb-2">
|
||||||
|
<div class="text-sm text-muted-foreground">{{ key }}</div>
|
||||||
|
<div class="font-medium">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const appSlug = computed(() => route.params.appSlug as string)
|
||||||
|
const pageSlug = computed(() => route.params.pageSlug as string)
|
||||||
|
const recordId = computed(() => route.params.recordId as string)
|
||||||
|
|
||||||
|
const record = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const objectApiName = ref('')
|
||||||
|
|
||||||
|
const fetchRecord = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// First get page metadata to know which object this is
|
||||||
|
const page = await api.get(`/runtime/apps/${appSlug.value}/pages/${pageSlug.value}`)
|
||||||
|
objectApiName.value = page.objectApiName
|
||||||
|
|
||||||
|
// Then fetch the record
|
||||||
|
record.value = await api.get(`/runtime/objects/${objectApiName.value}/records/${recordId.value}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecord()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
31
frontend/pages/index.vue
Normal file
31
frontend/pages/index.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<h1 class="text-2xl font-bold">Neo Platform</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div class="text-center space-y-6">
|
||||||
|
<h2 class="text-4xl font-bold">Welcome to Neo Platform</h2>
|
||||||
|
<p class="text-muted-foreground text-lg">
|
||||||
|
Multi-tenant application platform for building CRM, Project Management, and more
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<NuxtLink
|
||||||
|
to="/setup/apps"
|
||||||
|
class="px-6 py-3 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Setup Apps
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/setup/objects"
|
||||||
|
class="px-6 py-3 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Setup Objects
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
110
frontend/pages/login.vue
Normal file
110
frontend/pages/login.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md p-8 border rounded-lg bg-card">
|
||||||
|
<h1 class="text-3xl font-bold mb-6 text-center">Login</h1>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Tenant ID</label>
|
||||||
|
<input
|
||||||
|
v-model="tenantId"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Logging in...' : 'Login' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?
|
||||||
|
<NuxtLink to="/register" class="text-primary hover:underline">
|
||||||
|
Register
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const tenantId = ref('123')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-tenant-id': tenantId.value,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.message || 'Login failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Store credentials
|
||||||
|
localStorage.setItem('tenantId', tenantId.value)
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user))
|
||||||
|
|
||||||
|
// Redirect to home
|
||||||
|
router.push('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Login failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
138
frontend/pages/register.vue
Normal file
138
frontend/pages/register.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md p-8 border rounded-lg bg-card">
|
||||||
|
<h1 class="text-3xl font-bold mb-6 text-center">Register</h1>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="mb-4 p-3 bg-green-500/10 text-green-600 rounded">
|
||||||
|
Registration successful! Redirecting to login...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Tenant ID</label>
|
||||||
|
<input
|
||||||
|
v-model="tenantId"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">First Name (Optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="firstName"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Last Name (Optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="lastName"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Registering...' : 'Register' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?
|
||||||
|
<NuxtLink to="/login" class="text-primary hover:underline">
|
||||||
|
Login
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const tenantId = ref('123')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const firstName = ref('')
|
||||||
|
const lastName = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = false
|
||||||
|
|
||||||
|
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-tenant-id': tenantId.value,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
firstName: firstName.value || undefined,
|
||||||
|
lastName: lastName.value || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.message || 'Registration failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
success.value = true
|
||||||
|
|
||||||
|
// Redirect to login after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 2000)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Registration failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
67
frontend/pages/setup/apps/[slug].vue
Normal file
67
frontend/pages/setup/apps/[slug].vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else-if="app">
|
||||||
|
<div class="mb-6">
|
||||||
|
<NuxtLink to="/setup/apps" class="text-sm text-primary hover:underline">
|
||||||
|
← Back to Apps
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold mb-6">{{ app.label }}</h1>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4">Pages</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="page in app.pages"
|
||||||
|
:key="page.id"
|
||||||
|
class="p-4 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold">{{ page.label }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Type: {{ page.type }} | Slug: {{ page.slug }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const app = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const fetchApp = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
app.value = await api.get(`/setup/apps/${slug}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchApp()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
144
frontend/pages/setup/apps/index.vue
Normal file
144
frontend/pages/setup/apps/index.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<NuxtLink
|
||||||
|
to="/setup/apps"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Apps
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/setup/objects"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Objects
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Applications</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New App
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create New App</h2>
|
||||||
|
<form @submit.prevent="createApp" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Slug</label>
|
||||||
|
<input
|
||||||
|
v-model="newApp.slug"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="my-app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="newApp.label"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="My App"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newApp.description"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="app in apps"
|
||||||
|
:key="app.id"
|
||||||
|
:to="`/setup/apps/${app.slug}`"
|
||||||
|
class="p-6 border rounded-lg hover:border-primary transition-colors bg-card"
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">{{ app.label }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">{{ app.description || 'No description' }}</p>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-muted-foreground">{{ app.pages?.length || 0 }} pages</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const apps = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newApp = ref({
|
||||||
|
slug: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchApps = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
apps.value = await api.get('/setup/apps')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createApp = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/setup/apps', newApp.value)
|
||||||
|
showCreateForm.value = false
|
||||||
|
newApp.value = { slug: '', label: '', description: '' }
|
||||||
|
await fetchApps()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating app: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchApps()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
75
frontend/pages/setup/objects/[apiName].vue
Normal file
75
frontend/pages/setup/objects/[apiName].vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else-if="object">
|
||||||
|
<div class="mb-6">
|
||||||
|
<NuxtLink to="/setup/objects" class="text-sm text-primary hover:underline">
|
||||||
|
← Back to Objects
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold mb-6">{{ object.label }}</h1>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4">Fields</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="field in object.fields"
|
||||||
|
:key="field.id"
|
||||||
|
class="p-4 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold">{{ field.label }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Type: {{ field.type }} | API Name: {{ field.apiName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<span v-if="field.isRequired" class="px-2 py-1 bg-destructive/10 text-destructive rounded">
|
||||||
|
Required
|
||||||
|
</span>
|
||||||
|
<span v-if="field.isUnique" class="px-2 py-1 bg-primary/10 text-primary rounded">
|
||||||
|
Unique
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const object = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const fetchObject = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const apiName = route.params.apiName as string
|
||||||
|
object.value = await api.get(`/setup/objects/${apiName}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchObject()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
162
frontend/pages/setup/objects/index.vue
Normal file
162
frontend/pages/setup/objects/index.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<header class="border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<NuxtLink
|
||||||
|
to="/setup/apps"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Apps
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/setup/objects"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Objects
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Objects</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Object
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create New Object</h2>
|
||||||
|
<form @submit.prevent="createObject" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">API Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newObject.apiName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="MyObject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="newObject.label"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="My Object"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Plural Label</label>
|
||||||
|
<input
|
||||||
|
v-model="newObject.pluralLabel"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="My Objects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newObject.description"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="obj in objects"
|
||||||
|
:key="obj.id"
|
||||||
|
:to="`/setup/objects/${obj.apiName}`"
|
||||||
|
class="p-6 border rounded-lg hover:border-primary transition-colors bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h3 class="text-xl font-semibold">{{ obj.label }}</h3>
|
||||||
|
<span
|
||||||
|
v-if="obj.isSystem"
|
||||||
|
class="text-xs px-2 py-1 bg-muted text-muted-foreground rounded"
|
||||||
|
>
|
||||||
|
System
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">{{ obj.description || 'No description' }}</p>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-muted-foreground">{{ obj.fields?.length || 0 }} fields</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const objects = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newObject = ref({
|
||||||
|
apiName: '',
|
||||||
|
label: '',
|
||||||
|
pluralLabel: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchObjects = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
objects.value = await api.get('/setup/objects')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createObject = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/setup/objects', newObject.value)
|
||||||
|
showCreateForm.value = false
|
||||||
|
newObject.value = { apiName: '', label: '', pluralLabel: '', description: '' }
|
||||||
|
await fetchObjects()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating object: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchObjects()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
52
frontend/tailwind.config.js
Normal file
52
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./components/**/*.{js,vue,ts}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./plugins/**/*.{js,ts}',
|
||||||
|
'./app.vue',
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
75
infra/docker-compose.yml
Normal file
75
infra/docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform-api
|
||||||
|
command: npm run start:dev
|
||||||
|
env_file:
|
||||||
|
- ../.env.api
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ../backend:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- platform-network
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: platform-web
|
||||||
|
command: npm run dev -- --host 0.0.0.0 --port 3001
|
||||||
|
env_file:
|
||||||
|
- ../.env.web
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ../frontend:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- platform-network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: percona/percona-server:8.0
|
||||||
|
container_name: platform-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: platform
|
||||||
|
MYSQL_USER: platform
|
||||||
|
MYSQL_PASSWORD: platform
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- percona-data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- platform-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: platform-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
networks:
|
||||||
|
- platform-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
percona-data:
|
||||||
|
redis-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
platform-network:
|
||||||
|
driver: bridge
|
||||||
50
setup.sh
Executable file
50
setup.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Neo Platform - Development Setup Script
|
||||||
|
|
||||||
|
echo "🚀 Setting up Neo Platform..."
|
||||||
|
|
||||||
|
# Check if Docker is installed
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker is not installed. Please install Docker first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker Compose is installed
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose is not installed. Please install Docker Compose first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Docker and Docker Compose are installed"
|
||||||
|
|
||||||
|
# Navigate to infra directory
|
||||||
|
cd "$(dirname "$0")/infra" || exit
|
||||||
|
|
||||||
|
echo "📦 Building and starting containers..."
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo "🔄 Running database migrations..."
|
||||||
|
docker-compose exec api npx prisma migrate dev --name init
|
||||||
|
|
||||||
|
echo "✨ Generating Prisma client..."
|
||||||
|
docker-compose exec api npx prisma generate
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "📍 Services are running at:"
|
||||||
|
echo " - API: http://localhost:3000/api"
|
||||||
|
echo " - Web: http://localhost:3001"
|
||||||
|
echo " - Database: localhost:3306"
|
||||||
|
echo " - Redis: localhost:6379"
|
||||||
|
echo ""
|
||||||
|
echo "📝 Useful commands:"
|
||||||
|
echo " - View logs: cd infra && docker-compose logs -f"
|
||||||
|
echo " - Stop services: cd infra && docker-compose down"
|
||||||
|
echo " - Restart services: cd infra && docker-compose restart"
|
||||||
|
echo " - Run migrations: cd infra && docker-compose exec api npx prisma migrate dev"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user