Neo platform - First Version

This commit is contained in:
Francisco Gaona
2025-11-25 12:21:14 +01:00
commit 484af68571
59 changed files with 3699 additions and 0 deletions

9
.env.api Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

25
backend/Dockerfile Normal file
View 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
View 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
View 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"
}
}

View 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;

View 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"

View 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")
}

View 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 {}

View 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,
},
});
}
}

View 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,
);
}
}

View 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
View 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 {}

View 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;
}
}

View 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 {}

View 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;
}
}

View 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;
},
);

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View 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
View 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();

View 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 {}

View 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`);
}
}

View 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,
);
}
}

View 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,
);
}
}

View 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 {}

View 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();
}
}

View 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,
);
}
}

View 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);

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { RbacService } from './rbac.service';
@Module({
providers: [RbacService],
exports: [RbacService],
})
export class RbacModule {}

View 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,
})),
});
}
}

View 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));
}
}

View 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;
},
);

View 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();
}
}

View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
<template>
<div>
<NuxtPage />
</div>
</template>
<style>
@import 'assets/css/main.css';
</style>

View 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;
}
}

View 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
View 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
View 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"
}
}

View 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>

View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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 ""