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