From 484af68571d516d97ded3ee1b55310e1ac41cf0a Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Tue, 25 Nov 2025 12:21:14 +0100 Subject: [PATCH] Neo platform - First Version --- .env.api | 9 + .env.web | 5 + .gitignore | 56 +++ GETTING_STARTED.md | 345 ++++++++++++++++++ README.md | 115 ++++++ backend/.eslintrc.js | 25 ++ backend/.prettierrc | 4 + backend/Dockerfile | 25 ++ backend/nest-cli.json | 8 + backend/package.json | 82 +++++ .../20250101000000_init/migration.sql | 224 ++++++++++++ backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 227 ++++++++++++ backend/src/app-builder/app-builder.module.ts | 11 + .../src/app-builder/app-builder.service.ts | 242 ++++++++++++ .../src/app-builder/runtime-app.controller.ts | 45 +++ .../src/app-builder/setup-app.controller.ts | 69 ++++ backend/src/app.module.ts | 23 ++ backend/src/auth/auth.controller.ts | 82 +++++ backend/src/auth/auth.module.ts | 24 ++ backend/src/auth/auth.service.ts | 92 +++++ backend/src/auth/current-user.decorator.ts | 8 + backend/src/auth/jwt-auth.guard.ts | 5 + backend/src/auth/jwt.strategy.ts | 30 ++ backend/src/main.ts | 39 ++ backend/src/object/object.module.ts | 11 + backend/src/object/object.service.ts | 191 ++++++++++ .../src/object/runtime-object.controller.ts | 98 +++++ backend/src/object/setup-object.controller.ts | 51 +++ backend/src/prisma/prisma.module.ts | 9 + backend/src/prisma/prisma.service.ts | 16 + backend/src/rbac/permissions.guard.ts | 35 ++ backend/src/rbac/rbac.decorator.ts | 8 + backend/src/rbac/rbac.module.ts | 8 + backend/src/rbac/rbac.service.ts | 103 ++++++ backend/src/rbac/roles.guard.ts | 33 ++ backend/src/tenant/tenant.decorator.ts | 8 + backend/src/tenant/tenant.middleware.ts | 16 + backend/src/tenant/tenant.module.ts | 9 + backend/tsconfig.json | 22 ++ frontend/Dockerfile | 14 + frontend/app.vue | 9 + frontend/assets/css/main.css | 55 +++ frontend/composables/useApi.ts | 66 ++++ frontend/nuxt.config.ts | 32 ++ frontend/package.json | 33 ++ frontend/pages/app/[appSlug]/[pageSlug].vue | 87 +++++ .../app/[appSlug]/[pageSlug]/[recordId].vue | 67 ++++ frontend/pages/index.vue | 31 ++ frontend/pages/login.vue | 110 ++++++ frontend/pages/register.vue | 138 +++++++ frontend/pages/setup/apps/[slug].vue | 67 ++++ frontend/pages/setup/apps/index.vue | 144 ++++++++ frontend/pages/setup/objects/[apiName].vue | 75 ++++ frontend/pages/setup/objects/index.vue | 162 ++++++++ frontend/tailwind.config.js | 52 +++ frontend/tsconfig.json | 16 + infra/docker-compose.yml | 75 ++++ setup.sh | 50 +++ 59 files changed, 3699 insertions(+) create mode 100644 .env.api create mode 100644 .env.web create mode 100644 .gitignore create mode 100644 GETTING_STARTED.md create mode 100644 README.md create mode 100644 backend/.eslintrc.js create mode 100644 backend/.prettierrc create mode 100644 backend/Dockerfile create mode 100644 backend/nest-cli.json create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20250101000000_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/app-builder/app-builder.module.ts create mode 100644 backend/src/app-builder/app-builder.service.ts create mode 100644 backend/src/app-builder/runtime-app.controller.ts create mode 100644 backend/src/app-builder/setup-app.controller.ts create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/auth/auth.controller.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/auth.service.ts create mode 100644 backend/src/auth/current-user.decorator.ts create mode 100644 backend/src/auth/jwt-auth.guard.ts create mode 100644 backend/src/auth/jwt.strategy.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/object/object.module.ts create mode 100644 backend/src/object/object.service.ts create mode 100644 backend/src/object/runtime-object.controller.ts create mode 100644 backend/src/object/setup-object.controller.ts create mode 100644 backend/src/prisma/prisma.module.ts create mode 100644 backend/src/prisma/prisma.service.ts create mode 100644 backend/src/rbac/permissions.guard.ts create mode 100644 backend/src/rbac/rbac.decorator.ts create mode 100644 backend/src/rbac/rbac.module.ts create mode 100644 backend/src/rbac/rbac.service.ts create mode 100644 backend/src/rbac/roles.guard.ts create mode 100644 backend/src/tenant/tenant.decorator.ts create mode 100644 backend/src/tenant/tenant.middleware.ts create mode 100644 backend/src/tenant/tenant.module.ts create mode 100644 backend/tsconfig.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/app.vue create mode 100644 frontend/assets/css/main.css create mode 100644 frontend/composables/useApi.ts create mode 100644 frontend/nuxt.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pages/app/[appSlug]/[pageSlug].vue create mode 100644 frontend/pages/app/[appSlug]/[pageSlug]/[recordId].vue create mode 100644 frontend/pages/index.vue create mode 100644 frontend/pages/login.vue create mode 100644 frontend/pages/register.vue create mode 100644 frontend/pages/setup/apps/[slug].vue create mode 100644 frontend/pages/setup/apps/index.vue create mode 100644 frontend/pages/setup/objects/[apiName].vue create mode 100644 frontend/pages/setup/objects/index.vue create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 infra/docker-compose.yml create mode 100755 setup.sh diff --git a/.env.api b/.env.api new file mode 100644 index 0000000..0227401 --- /dev/null +++ b/.env.api @@ -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" diff --git a/.env.web b/.env.web new file mode 100644 index 0000000..3c1392b --- /dev/null +++ b/.env.web @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812ec34 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 0000000..5d578e4 --- /dev/null +++ b/GETTING_STARTED.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e15b70 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/backend/.eslintrc.js @@ -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', + }, +}; diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..16e6ca4 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..24dba11 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/migrations/20250101000000_init/migration.sql b/backend/prisma/migrations/20250101000000_init/migration.sql new file mode 100644 index 0000000..d5b7037 --- /dev/null +++ b/backend/prisma/migrations/20250101000000_init/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..9bee74d --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..a2c85e5 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/src/app-builder/app-builder.module.ts b/backend/src/app-builder/app-builder.module.ts new file mode 100644 index 0000000..ce29bb1 --- /dev/null +++ b/backend/src/app-builder/app-builder.module.ts @@ -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 {} diff --git a/backend/src/app-builder/app-builder.service.ts b/backend/src/app-builder/app-builder.service.ts new file mode 100644 index 0000000..5b0a840 --- /dev/null +++ b/backend/src/app-builder/app-builder.service.ts @@ -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, + }, + }); + } +} diff --git a/backend/src/app-builder/runtime-app.controller.ts b/backend/src/app-builder/runtime-app.controller.ts new file mode 100644 index 0000000..8364372 --- /dev/null +++ b/backend/src/app-builder/runtime-app.controller.ts @@ -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, + ); + } +} diff --git a/backend/src/app-builder/setup-app.controller.ts b/backend/src/app-builder/setup-app.controller.ts new file mode 100644 index 0000000..dbf29f9 --- /dev/null +++ b/backend/src/app-builder/setup-app.controller.ts @@ -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, + ); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..bfdc051 --- /dev/null +++ b/backend/src/app.module.ts @@ -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 {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..d1236db --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -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; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..bd88d27 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -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('JWT_SECRET', 'devsecret'), + signOptions: { expiresIn: '24h' }, + }), + }), + ], + providers: [AuthService, JwtStrategy], + controllers: [AuthController], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..a518c4a --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -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 { + 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; + } +} diff --git a/backend/src/auth/current-user.decorator.ts b/backend/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..7919497 --- /dev/null +++ b/backend/src/auth/current-user.decorator.ts @@ -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; + }, +); diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..29c3809 --- /dev/null +++ b/backend/src/auth/jwt.strategy.ts @@ -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('JWT_SECRET', 'devsecret'), + }); + } + + async validate(payload: any) { + return { + userId: payload.sub, + email: payload.email, + tenantId: payload.tenantId, + }; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..dce6237 --- /dev/null +++ b/backend/src/main.ts @@ -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( + 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(); diff --git a/backend/src/object/object.module.ts b/backend/src/object/object.module.ts new file mode 100644 index 0000000..6587540 --- /dev/null +++ b/backend/src/object/object.module.ts @@ -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 {} diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts new file mode 100644 index 0000000..67615c4 --- /dev/null +++ b/backend/src/object/object.service.ts @@ -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`); + } +} diff --git a/backend/src/object/runtime-object.controller.ts b/backend/src/object/runtime-object.controller.ts new file mode 100644 index 0000000..6a55e05 --- /dev/null +++ b/backend/src/object/runtime-object.controller.ts @@ -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, + ); + } +} diff --git a/backend/src/object/setup-object.controller.ts b/backend/src/object/setup-object.controller.ts new file mode 100644 index 0000000..05ee44c --- /dev/null +++ b/backend/src/object/setup-object.controller.ts @@ -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, + ); + } +} diff --git a/backend/src/prisma/prisma.module.ts b/backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..23c626e --- /dev/null +++ b/backend/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..7ffd32d --- /dev/null +++ b/backend/src/prisma/prisma.service.ts @@ -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(); + } +} diff --git a/backend/src/rbac/permissions.guard.ts b/backend/src/rbac/permissions.guard.ts new file mode 100644 index 0000000..fab49df --- /dev/null +++ b/backend/src/rbac/permissions.guard.ts @@ -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 { + const requiredPermissions = this.reflector.getAllAndOverride( + 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, + ); + } +} diff --git a/backend/src/rbac/rbac.decorator.ts b/backend/src/rbac/rbac.decorator.ts new file mode 100644 index 0000000..5d20a8e --- /dev/null +++ b/backend/src/rbac/rbac.decorator.ts @@ -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); diff --git a/backend/src/rbac/rbac.module.ts b/backend/src/rbac/rbac.module.ts new file mode 100644 index 0000000..2e7af4d --- /dev/null +++ b/backend/src/rbac/rbac.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RbacService } from './rbac.service'; + +@Module({ + providers: [RbacService], + exports: [RbacService], +}) +export class RbacModule {} diff --git a/backend/src/rbac/rbac.service.ts b/backend/src/rbac/rbac.service.ts new file mode 100644 index 0000000..01df8c9 --- /dev/null +++ b/backend/src/rbac/rbac.service.ts @@ -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 { + const userRoles = await this.prisma.userRole.findMany({ + where: { userId }, + include: { + role: { + include: { + rolePermissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }); + + const permissions = new Set(); + userRoles.forEach((userRole) => { + userRole.role.rolePermissions.forEach((rp) => { + permissions.add(rp.permission.name); + }); + }); + + return Array.from(permissions); + } + + async getUserRoles(userId: string): Promise { + 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 { + const permissions = await this.getUserPermissions(userId); + return permissions.includes(permission); + } + + async hasRole(userId: string, role: string): Promise { + const roles = await this.getUserRoles(userId); + return roles.includes(role); + } + + async hasAnyPermission( + userId: string, + permissions: string[], + ): Promise { + const userPermissions = await this.getUserPermissions(userId); + return permissions.some((p) => userPermissions.includes(p)); + } + + async hasAllPermissions( + userId: string, + permissions: string[], + ): Promise { + 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, + })), + }); + } +} diff --git a/backend/src/rbac/roles.guard.ts b/backend/src/rbac/roles.guard.ts new file mode 100644 index 0000000..78c37e5 --- /dev/null +++ b/backend/src/rbac/roles.guard.ts @@ -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 { + const requiredRoles = this.reflector.getAllAndOverride( + 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)); + } +} diff --git a/backend/src/tenant/tenant.decorator.ts b/backend/src/tenant/tenant.decorator.ts new file mode 100644 index 0000000..97b596b --- /dev/null +++ b/backend/src/tenant/tenant.decorator.ts @@ -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; + }, +); diff --git a/backend/src/tenant/tenant.middleware.ts b/backend/src/tenant/tenant.middleware.ts new file mode 100644 index 0000000..23455aa --- /dev/null +++ b/backend/src/tenant/tenant.middleware.ts @@ -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(); + } +} diff --git a/backend/src/tenant/tenant.module.ts b/backend/src/tenant/tenant.module.ts new file mode 100644 index 0000000..cb091c5 --- /dev/null +++ b/backend/src/tenant/tenant.module.ts @@ -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('*'); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..5485d9d --- /dev/null +++ b/backend/tsconfig.json @@ -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 + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..847d49d --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/app.vue b/frontend/app.vue new file mode 100644 index 0000000..698786c --- /dev/null +++ b/frontend/app.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css new file mode 100644 index 0000000..5b9dde0 --- /dev/null +++ b/frontend/assets/css/main.css @@ -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; + } +} diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts new file mode 100644 index 0000000..0cf38f7 --- /dev/null +++ b/frontend/composables/useApi.ts @@ -0,0 +1,66 @@ +export const useApi = () => { + const config = useRuntimeConfig() + const apiBaseUrl = config.public.apiBaseUrl + + const getHeaders = () => { + const headers: Record = { + '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 } +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts new file mode 100644 index 0000000..af34ad6 --- /dev/null +++ b/frontend/nuxt.config.ts @@ -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', +}) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1c61f9d --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/pages/app/[appSlug]/[pageSlug].vue b/frontend/pages/app/[appSlug]/[pageSlug].vue new file mode 100644 index 0000000..1a82598 --- /dev/null +++ b/frontend/pages/app/[appSlug]/[pageSlug].vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/pages/app/[appSlug]/[pageSlug]/[recordId].vue b/frontend/pages/app/[appSlug]/[pageSlug]/[recordId].vue new file mode 100644 index 0000000..fc51700 --- /dev/null +++ b/frontend/pages/app/[appSlug]/[pageSlug]/[recordId].vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue new file mode 100644 index 0000000..002eaf2 --- /dev/null +++ b/frontend/pages/index.vue @@ -0,0 +1,31 @@ + diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue new file mode 100644 index 0000000..f3ab32a --- /dev/null +++ b/frontend/pages/login.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/pages/register.vue b/frontend/pages/register.vue new file mode 100644 index 0000000..b2f85fa --- /dev/null +++ b/frontend/pages/register.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/pages/setup/apps/[slug].vue b/frontend/pages/setup/apps/[slug].vue new file mode 100644 index 0000000..86668bf --- /dev/null +++ b/frontend/pages/setup/apps/[slug].vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/pages/setup/apps/index.vue b/frontend/pages/setup/apps/index.vue new file mode 100644 index 0000000..9fc0dbc --- /dev/null +++ b/frontend/pages/setup/apps/index.vue @@ -0,0 +1,144 @@ +