Compare commits
1 Commits
Developmen
...
Master
| Author | SHA1 | Date |
|---|---|---|
|
|
70b3c77fa6 | 46 minutes ago |
@ -0,0 +1,4 @@
|
||||
|
||||
VITE_SUPABASE_URL=https://krkgvhstckknyylgqvyk.supabase.co
|
||||
VITE_SUPABASE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtya2d2aHN0Y2trbnl5bGdxdnlrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYzODI5MTIsImV4cCI6MjA4MTk1ODkxMn0.T05AMrHbcfHZIR7wMcIOaWGDuYGjFEOgRaZ_lz4DPPg
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtya2d2aHN0Y2trbnl5bGdxdnlrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYzODI5MTIsImV4cCI6MjA4MTk1ODkxMn0.T05AMrHbcfHZIR7wMcIOaWGDuYGjFEOgRaZ_lz4DPPg
|
||||
@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
dist/
|
||||
frontend/dist/
|
||||
tsconfig.tsbuildinfo
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
backend/dist/
|
||||
backend/.env
|
||||
@ -0,0 +1,69 @@
|
||||
# Database Setup Guide
|
||||
|
||||
## Problem
|
||||
You're getting a foreign key constraint error when trying to add an organization because the MySQL database isn't running.
|
||||
|
||||
## Quick Fix with Docker
|
||||
|
||||
1. **Start the MySQL database using Docker:**
|
||||
```bash
|
||||
cd backend
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Wait a few seconds for MySQL to start, then restart your backend server**
|
||||
|
||||
3. **In your frontend, log out and log back in:**
|
||||
- Clear browser local storage or click logout
|
||||
- Log in again (or sign up if needed)
|
||||
- Now try creating an organization
|
||||
|
||||
## Alternative: Use Existing MySQL Installation
|
||||
|
||||
If you already have MySQL installed:
|
||||
|
||||
1. **Start MySQL service:**
|
||||
- Windows (XAMPP/WAMP): Start from control panel
|
||||
- Mac: `brew services start mysql`
|
||||
- Linux: `sudo systemctl start mysql`
|
||||
|
||||
2. **Create the database:**
|
||||
```bash
|
||||
mysql -u root -p
|
||||
```
|
||||
Then run:
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS verto;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
3. **Restart your backend server**
|
||||
|
||||
4. **Log out and log back in to your application**
|
||||
|
||||
## Verify Database Connection
|
||||
|
||||
Run this command from the backend directory to check your database:
|
||||
```bash
|
||||
node check-db.js
|
||||
```
|
||||
|
||||
This will show you:
|
||||
- If the database connection is working
|
||||
- How many users exist
|
||||
- How many organizations exist
|
||||
|
||||
## Why This Happened
|
||||
|
||||
The error occurred because:
|
||||
1. Your JWT authentication token contains a user ID
|
||||
2. When creating an organization, the system tries to link it to that user
|
||||
3. But the user doesn't exist in the database (because MySQL wasn't running or the DB was reset)
|
||||
4. MySQL's foreign key constraint prevents creating an organization without a valid user
|
||||
|
||||
## After Fixing
|
||||
|
||||
Once the database is running and you've logged back in:
|
||||
- You'll be able to create organizations
|
||||
- The Client Mapping feature will work properly
|
||||
- All data will persist correctly
|
||||
@ -0,0 +1,16 @@
|
||||
PORT=3000
|
||||
CLIENT_URL=http://localhost:5173
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=root
|
||||
DATABASE_PASSWORD=root
|
||||
DATABASE_NAME=verto
|
||||
JWT_SECRET=super-secret
|
||||
JWT_EXPIRES_IN=1d
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=enaeemullah@gmail.com
|
||||
SMTP_PASSWORD=wkyrwkvbskagjrcn
|
||||
EMAIL_FROM="Verto <enaeemullah@gmail.com>"
|
||||
PROJECT_INVITE_TTL_HOURS=72
|
||||
@ -0,0 +1,17 @@
|
||||
NODE_ENV=test
|
||||
PORT=3001
|
||||
CLIENT_URL=http://localhost:4173
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=root
|
||||
DATABASE_PASSWORD=root
|
||||
DATABASE_NAME=verto_test
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRES_IN=1d
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=test@example.com
|
||||
SMTP_PASSWORD=test-password
|
||||
EMAIL_FROM="Client Release Manager <test@example.com>"
|
||||
PROJECT_INVITE_TTL_HOURS=72
|
||||
@ -0,0 +1,17 @@
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
CLIENT_URL=http://localhost:5173
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=root
|
||||
DATABASE_PASSWORD=change-me
|
||||
DATABASE_NAME=verto
|
||||
JWT_SECRET=change-me
|
||||
JWT_EXPIRES_IN=1d
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=no-reply@example.com
|
||||
SMTP_PASSWORD=change-me
|
||||
EMAIL_FROM="Client Release Manager <no-reply@example.com>"
|
||||
PROJECT_INVITE_TTL_HOURS=72
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
@ -0,0 +1,44 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config({ path: '.env-development' });
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: process.env.DATABASE_PORT || 3306,
|
||||
user: process.env.DATABASE_USER || 'root',
|
||||
password: process.env.DATABASE_PASSWORD || '',
|
||||
database: process.env.DATABASE_NAME || 'verto',
|
||||
});
|
||||
|
||||
console.log('✓ Database connection successful');
|
||||
console.log(`Database: ${process.env.DATABASE_NAME || 'verto'}`);
|
||||
|
||||
const [users] = await connection.execute('SELECT id, email, firstName, lastName FROM users');
|
||||
console.log(`\nUsers in database: ${users.length}`);
|
||||
if (users.length > 0) {
|
||||
console.log('\nUser list:');
|
||||
users.forEach(user => {
|
||||
console.log(` - ${user.email} (ID: ${user.id})`);
|
||||
});
|
||||
} else {
|
||||
console.log('\n⚠ No users found in database. You need to sign up first.');
|
||||
}
|
||||
|
||||
const [projects] = await connection.execute('SELECT id, name, slug, ownerId FROM projects');
|
||||
console.log(`\nProjects/Organizations in database: ${projects.length}`);
|
||||
if (projects.length > 0) {
|
||||
console.log('\nProject list:');
|
||||
projects.forEach(project => {
|
||||
console.log(` - ${project.name} (${project.slug}) - Owner ID: ${project.ownerId}`);
|
||||
});
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
} catch (error) {
|
||||
console.error('✗ Database connection failed:', error.message);
|
||||
console.error('\nPlease check your database configuration in backend/.env-development');
|
||||
}
|
||||
}
|
||||
|
||||
checkDatabase();
|
||||
@ -0,0 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: verto-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: verto
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs', 'src/**/*.spec.ts', 'test/**/*.ts'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"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": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('health', () => {
|
||||
it('should report a healthy status', () => {
|
||||
expect(appController.getHealth()).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'ok',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller('health')
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHealth() {
|
||||
return this.appService.getHealth();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ReleasesModule } from './releases/releases.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { resolve } from 'node:path';
|
||||
import { TransactionEventsModule } from './transaction-events/transaction-events.module';
|
||||
import { TransactionCodesModule } from './transaction-codes/transaction-codes.module';
|
||||
|
||||
const envFilePath = (() => {
|
||||
const env = process.env.NODE_ENV ?? 'development';
|
||||
const envFileMap: Record<string, string[]> = {
|
||||
development: ['.env-development', '.env'],
|
||||
test: ['.env-test', '.env'],
|
||||
production: ['.env'],
|
||||
};
|
||||
|
||||
const files = envFileMap[env] ?? envFileMap.development;
|
||||
return files.map((file) => resolve(__dirname, '..', file));
|
||||
})();
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath,
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'mysql',
|
||||
host: config.get<string>('DATABASE_HOST', 'localhost'),
|
||||
port: Number(config.get('DATABASE_PORT', 3306)),
|
||||
username: config.get<string>('DATABASE_USER', 'root'),
|
||||
password: config.get<string>('DATABASE_PASSWORD', ''),
|
||||
database: config.get<string>('DATABASE_NAME', 'verto'),
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
ReleasesModule,
|
||||
ProjectsModule,
|
||||
TransactionEventsModule,
|
||||
TransactionCodesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AcceptInviteDto } from './dto/accept-invite.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('signup')
|
||||
signup(@Body() dto: SignupDto) {
|
||||
return this.authService.signup(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Get('invitations/:token')
|
||||
getInviteDetails(@Param('token') token: string) {
|
||||
return this.authService.previewInvite(token);
|
||||
}
|
||||
|
||||
@Post('invitations/accept')
|
||||
acceptInvite(@Body() dto: AcceptInviteDto) {
|
||||
return this.authService.acceptInvite(dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from '../security/jwt.strategy';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
ProjectsModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET', 'super-secret'),
|
||||
signOptions: { expiresIn: 24 * 60 * 60 }
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@ -0,0 +1,90 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { AcceptInviteDto } from './dto/accept-invite.dto';
|
||||
import { ProjectInvitesService } from '../projects/project-invites.service';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly projectInvitesService: ProjectInvitesService,
|
||||
) {}
|
||||
|
||||
async signup(dto: SignupDto) {
|
||||
const normalizedEmail = dto.email.trim().toLowerCase();
|
||||
const existing = await this.usersService.findByEmail(normalizedEmail);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already in use');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.usersService.create(normalizedEmail, passwordHash, {
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
});
|
||||
|
||||
return this.buildAuthResponse(user);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const normalizedEmail = dto.email.trim().toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
|
||||
if (!isMatch) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
return this.buildAuthResponse(user);
|
||||
}
|
||||
|
||||
previewInvite(token: string) {
|
||||
return this.projectInvitesService.getInviteDetails(token);
|
||||
}
|
||||
|
||||
async acceptInvite(dto: AcceptInviteDto) {
|
||||
const invite = await this.projectInvitesService.getInviteDetails(dto.token);
|
||||
let user = await this.usersService.findByEmail(invite.email);
|
||||
|
||||
if (!user) {
|
||||
if (!dto.password) {
|
||||
throw new BadRequestException('Password is required to create your account');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
user = await this.usersService.create(invite.email, passwordHash);
|
||||
}
|
||||
|
||||
await this.projectInvitesService.consumeInvite(dto.token, user.id);
|
||||
return this.buildAuthResponse(user);
|
||||
}
|
||||
|
||||
private buildAuthResponse(user: User) {
|
||||
const token = this.jwtService.sign({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: this.usersService.toProfile(user),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class AcceptInviteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { LoginDto } from './login.dto';
|
||||
|
||||
export class SignupDto extends LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
lastName: string;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
app.enableCors({
|
||||
origin: configService.get<string>('CLIENT_URL') ?? true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const port = Number(configService.get('PORT', 3000));
|
||||
await app.listen(port);
|
||||
logger.log(`Server listening on port ${port}`);
|
||||
}
|
||||
bootstrap().catch((error) => {
|
||||
Logger.error('Failed to start Nest application', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateOrganizationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
code: string;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class CreateProjectInviteDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../security/jwt-auth.guard';
|
||||
import { CurrentUser } from '../security/user.decorator';
|
||||
import type { JwtPayload } from '../security/jwt-payload.interface';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
|
||||
@Controller('organizations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OrganizationsController {
|
||||
constructor(private readonly projectsService: ProjectsService) {}
|
||||
|
||||
@Get()
|
||||
listOrganizations(@CurrentUser() user: JwtPayload) {
|
||||
return this.projectsService.getAccessibleOrganizations(user.sub);
|
||||
}
|
||||
|
||||
@Post()
|
||||
createOrganization(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrganizationDto) {
|
||||
return this.projectsService.createOrganization(user.sub, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Project } from './project.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
export type ProjectActivityAction =
|
||||
| 'project_created'
|
||||
| 'release_upserted'
|
||||
| 'release_deleted'
|
||||
| 'transaction_event_created'
|
||||
| 'transaction_event_updated';
|
||||
|
||||
@Entity('project_activity_logs')
|
||||
export class ProjectActivityLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
projectId: string;
|
||||
|
||||
@ManyToOne(() => Project, (project) => project.activityLogs, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project: Project;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.projectActivityLogs, {
|
||||
onDelete: 'SET NULL',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 60 })
|
||||
action: ProjectActivityAction;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata: Record<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Project } from './project.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Entity('project_invites')
|
||||
@Index(['projectId', 'email'], { unique: true })
|
||||
@Index(['token'], { unique: true })
|
||||
export class ProjectInvite {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
projectId: string;
|
||||
|
||||
@Column()
|
||||
invitedById: string;
|
||||
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
acceptedAt: Date | null;
|
||||
|
||||
@ManyToOne(() => Project, (project) => project.invites, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
project: Project;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
invitedBy: User;
|
||||
}
|
||||
@ -0,0 +1,235 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { IsNull, MoreThan, Repository } from 'typeorm';
|
||||
import { normalizeKey } from '../shared/normalize-key';
|
||||
import { ProjectInvite } from './project-invite.entity';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
const DEFAULT_INVITE_TTL_HOURS = 72;
|
||||
|
||||
@Injectable()
|
||||
export class ProjectInvitesService {
|
||||
constructor(
|
||||
@InjectRepository(ProjectInvite)
|
||||
private readonly invitesRepository: Repository<ProjectInvite>,
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async createInvite(ownerId: string, client: string, rawEmail: string) {
|
||||
const project = await this.projectsService.findOwnedProjectBySlug(ownerId, client);
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
const email = normalizeKey(rawEmail);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException('Email is required');
|
||||
}
|
||||
const inviter = await this.usersService.findById(ownerId);
|
||||
|
||||
if (!inviter) {
|
||||
throw new NotFoundException('Inviter not found');
|
||||
}
|
||||
|
||||
if (inviter.email === email) {
|
||||
throw new BadRequestException('You cannot invite yourself');
|
||||
}
|
||||
|
||||
const existingUser = await this.usersService.findByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
const alreadyMember = await this.projectsService.isUserInProject(project.id, existingUser.id);
|
||||
if (alreadyMember) {
|
||||
throw new ConflictException('User already has access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
let invite = await this.invitesRepository.findOne({
|
||||
where: { projectId: project.id, email },
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
invite = this.invitesRepository.create({
|
||||
projectId: project.id,
|
||||
invitedById: ownerId,
|
||||
email,
|
||||
token: '',
|
||||
expiresAt: new Date(),
|
||||
acceptedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
invite.token = randomBytes(32).toString('hex');
|
||||
invite.expiresAt = this.buildExpiryDate();
|
||||
invite.acceptedAt = null;
|
||||
|
||||
await this.invitesRepository.save(invite);
|
||||
|
||||
const inviteUrl = this.buildInviteUrl(invite.token);
|
||||
|
||||
await this.emailService.sendProjectInvite(email, {
|
||||
inviteLink: inviteUrl,
|
||||
inviterEmail: inviter.email,
|
||||
projectName: project.name,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getInviteDetails(token: string) {
|
||||
const invite = await this.findActiveInvite(token);
|
||||
return {
|
||||
email: invite.email,
|
||||
projectName: invite.project.name,
|
||||
client: invite.project.slug,
|
||||
inviterEmail: invite.invitedBy?.email ?? '',
|
||||
expiresAt: invite.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async consumeInvite(token: string, userId: string) {
|
||||
const invite = await this.findActiveInvite(token);
|
||||
await this.projectsService.ensureMembership(invite.projectId, userId);
|
||||
await this.invitesRepository.remove(invite);
|
||||
return invite;
|
||||
}
|
||||
|
||||
async getPendingInvitesForUser(userId: string) {
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const invites = await this.invitesRepository.find({
|
||||
where: {
|
||||
email: user.email,
|
||||
acceptedAt: IsNull(),
|
||||
expiresAt: MoreThan(new Date()),
|
||||
},
|
||||
relations: {
|
||||
project: true,
|
||||
invitedBy: true,
|
||||
},
|
||||
order: {
|
||||
expiresAt: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
return invites.map((invite) => this.toPendingInvite(invite));
|
||||
}
|
||||
|
||||
async acceptInviteForUser(inviteId: string, userId: string) {
|
||||
const invite = await this.findInviteForUser(inviteId, userId);
|
||||
await this.projectsService.ensureMembership(invite.projectId, userId);
|
||||
await this.invitesRepository.remove(invite);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async rejectInviteForUser(inviteId: string, userId: string) {
|
||||
const invite = await this.findInviteForUser(inviteId, userId);
|
||||
await this.invitesRepository.remove(invite);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async findActiveInvite(token: string) {
|
||||
const normalizedToken = token.trim();
|
||||
const invite = await this.invitesRepository.findOne({
|
||||
where: { token: normalizedToken },
|
||||
relations: ['project', 'invitedBy', 'project.owner'],
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new NotFoundException('Invite not found');
|
||||
}
|
||||
|
||||
if (invite.acceptedAt) {
|
||||
throw new BadRequestException('Invite already used');
|
||||
}
|
||||
|
||||
if (invite.expiresAt.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Invite expired');
|
||||
}
|
||||
|
||||
return invite;
|
||||
}
|
||||
|
||||
private async findInviteForUser(inviteId: string, userId: string) {
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const invite = await this.invitesRepository.findOne({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
relations: ['project', 'invitedBy', 'project.owner'],
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new NotFoundException('Invite not found');
|
||||
}
|
||||
|
||||
if (invite.expiresAt.getTime() < Date.now()) {
|
||||
await this.invitesRepository.remove(invite);
|
||||
throw new BadRequestException('Invite expired');
|
||||
}
|
||||
|
||||
return invite;
|
||||
}
|
||||
|
||||
private toPendingInvite(invite: ProjectInvite) {
|
||||
return {
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
expiresAt: invite.expiresAt.toISOString(),
|
||||
project: {
|
||||
id: invite.project.id,
|
||||
name: invite.project.name,
|
||||
slug: invite.project.slug,
|
||||
},
|
||||
invitedBy: invite.invitedBy
|
||||
? {
|
||||
id: invite.invitedBy.id,
|
||||
email: invite.invitedBy.email,
|
||||
displayName: invite.invitedBy.displayName ?? null,
|
||||
firstName: invite.invitedBy.firstName ?? null,
|
||||
lastName: invite.invitedBy.lastName ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildExpiryDate() {
|
||||
const ttlHours = Number(
|
||||
this.configService.get<string>('PROJECT_INVITE_TTL_HOURS') ??
|
||||
DEFAULT_INVITE_TTL_HOURS,
|
||||
);
|
||||
|
||||
const hours = Number.isFinite(ttlHours) ? ttlHours : DEFAULT_INVITE_TTL_HOURS;
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
private buildInviteUrl(token: string) {
|
||||
const clientUrl =
|
||||
this.configService.get<string>('CLIENT_URL') ?? 'http://localhost:5173';
|
||||
const cleanBase = clientUrl.replace(/\/$/, '');
|
||||
return `${cleanBase}/?inviteToken=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Project } from './project.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
export type ProjectRole = 'owner' | 'editor';
|
||||
|
||||
@Entity('project_members')
|
||||
@Index(['projectId', 'userId'], { unique: true })
|
||||
export class ProjectMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
projectId: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column({ default: 'editor' })
|
||||
role: ProjectRole;
|
||||
|
||||
@ManyToOne(() => Project, (project) => project.members, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
project: Project;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.projectMemberships, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
user: User;
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../users/user.entity';
|
||||
import { Release } from '../releases/release.entity';
|
||||
import { ProjectMember } from './project-member.entity';
|
||||
import { ProjectInvite } from './project-invite.entity';
|
||||
import { ProjectActivityLog } from './project-activity-log.entity';
|
||||
import { TransactionEvent } from '../transaction-events/transaction-event.entity';
|
||||
|
||||
@Entity('projects')
|
||||
@Index(['ownerId', 'slug'], { unique: true })
|
||||
export class Project {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
slug: string;
|
||||
|
||||
@Column()
|
||||
ownerId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.ownedProjects, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
owner: User;
|
||||
|
||||
@OneToMany(() => Release, (release) => release.project)
|
||||
releases: Release[];
|
||||
|
||||
@OneToMany(() => ProjectMember, (member) => member.project)
|
||||
members: ProjectMember[];
|
||||
|
||||
@OneToMany(() => ProjectInvite, (invite) => invite.project)
|
||||
invites: ProjectInvite[];
|
||||
|
||||
@OneToMany(() => ProjectActivityLog, (log) => log.project)
|
||||
activityLogs: ProjectActivityLog[];
|
||||
|
||||
@OneToMany(() => TransactionEvent, (transaction) => transaction.project)
|
||||
transactionEvents: TransactionEvent[];
|
||||
|
||||
@Column({ nullable: true })
|
||||
lastUpdatedById: string | null;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'lastUpdatedById' })
|
||||
lastUpdatedBy: User | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastActivityAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../security/jwt-auth.guard';
|
||||
import { CurrentUser } from '../security/user.decorator';
|
||||
import type { JwtPayload } from '../security/jwt-payload.interface';
|
||||
import { ProjectInvitesService } from './project-invites.service';
|
||||
import { CreateProjectInviteDto } from './dto/create-project-invite.dto';
|
||||
import { ProjectsService } from './projects.service';
|
||||
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly projectInvitesService: ProjectInvitesService,
|
||||
private readonly projectsService: ProjectsService,
|
||||
) {}
|
||||
|
||||
@Post(':client/invitations')
|
||||
inviteUser(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('client') client: string,
|
||||
@Body() dto: CreateProjectInviteDto,
|
||||
) {
|
||||
return this.projectInvitesService.createInvite(user.sub, client, dto.email);
|
||||
}
|
||||
|
||||
@Get('invitations')
|
||||
listPendingInvites(@CurrentUser() user: JwtPayload) {
|
||||
return this.projectInvitesService.getPendingInvitesForUser(user.sub);
|
||||
}
|
||||
|
||||
@Post('invitations/:inviteId/accept')
|
||||
acceptPendingInvite(@CurrentUser() user: JwtPayload, @Param('inviteId') inviteId: string) {
|
||||
return this.projectInvitesService.acceptInviteForUser(inviteId, user.sub);
|
||||
}
|
||||
|
||||
@Post('invitations/:inviteId/reject')
|
||||
rejectPendingInvite(@CurrentUser() user: JwtPayload, @Param('inviteId') inviteId: string) {
|
||||
return this.projectInvitesService.rejectInviteForUser(inviteId, user.sub);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
listActivity(@CurrentUser() user: JwtPayload) {
|
||||
return this.projectsService.getActivitySummaries(user.sub);
|
||||
}
|
||||
|
||||
@Get(':client/activity')
|
||||
getProjectActivity(@CurrentUser() user: JwtPayload, @Param('client') client: string) {
|
||||
return this.projectsService.getProjectActivity(user.sub, client);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Project } from './project.entity';
|
||||
import { ProjectMember } from './project-member.entity';
|
||||
import { ProjectInvite } from './project-invite.entity';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { ProjectInvitesService } from './project-invites.service';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
import { ProjectActivityLog } from './project-activity-log.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Project, ProjectMember, ProjectInvite, ProjectActivityLog]),
|
||||
UsersModule,
|
||||
EmailModule,
|
||||
],
|
||||
providers: [ProjectsService, ProjectInvitesService],
|
||||
exports: [ProjectsService, ProjectInvitesService],
|
||||
controllers: [ProjectsController, OrganizationsController],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
@ -0,0 +1,368 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { normalizeKey } from '../shared/normalize-key';
|
||||
import { Project } from './project.entity';
|
||||
import { ProjectMember, ProjectRole } from './project-member.entity';
|
||||
import { ProjectActivityAction, ProjectActivityLog } from './project-activity-log.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
|
||||
const DEFAULT_LOG_LIMIT = 10;
|
||||
|
||||
export interface ProjectActivityUserDto {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectActivityLogDto {
|
||||
id: string;
|
||||
action: ProjectActivityAction;
|
||||
createdAt: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
user: ProjectActivityUserDto | null;
|
||||
}
|
||||
|
||||
export interface ProjectActivitySummaryDto {
|
||||
projectId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
lastUpdatedAt: string | null;
|
||||
lastUpdatedBy: ProjectActivityUserDto | null;
|
||||
recentLogs: ProjectActivityLogDto[];
|
||||
}
|
||||
|
||||
export interface OrganizationSummaryDto {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private readonly projectsRepository: Repository<Project>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly membersRepository: Repository<ProjectMember>,
|
||||
@InjectRepository(ProjectActivityLog)
|
||||
private readonly activityRepository: Repository<ProjectActivityLog>,
|
||||
) {}
|
||||
|
||||
async getAccessibleProjectIds(userId: string): Promise<string[]> {
|
||||
const [owned, memberships] = await Promise.all([
|
||||
this.projectsRepository.find({
|
||||
where: { ownerId: userId },
|
||||
select: { id: true },
|
||||
}),
|
||||
this.membersRepository.find({
|
||||
where: { userId },
|
||||
select: { projectId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const ids = new Set<string>();
|
||||
owned.forEach((project) => ids.add(project.id));
|
||||
memberships.forEach((member) => ids.add(member.projectId));
|
||||
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
async findAccessibleProjectBySlug(userId: string, slug: string) {
|
||||
const normalizedSlug = normalizeKey(slug);
|
||||
|
||||
const project = await this.projectsRepository
|
||||
.createQueryBuilder('project')
|
||||
.leftJoin(
|
||||
ProjectMember,
|
||||
'member',
|
||||
'member.projectId = project.id AND member.userId = :userId',
|
||||
{ userId },
|
||||
)
|
||||
.where(
|
||||
'(project.ownerId = :userId OR member.userId IS NOT NULL) AND project.slug = :slug',
|
||||
{ userId, slug: normalizedSlug },
|
||||
)
|
||||
.getOne();
|
||||
|
||||
return project ?? null;
|
||||
}
|
||||
|
||||
async findOwnedProjectBySlug(ownerId: string, slug: string) {
|
||||
return this.projectsRepository.findOne({
|
||||
where: { ownerId, slug: normalizeKey(slug) },
|
||||
});
|
||||
}
|
||||
|
||||
async ensureProjectForUser(userId: string, clientName: string) {
|
||||
const slug = normalizeKey(clientName);
|
||||
const existing = await this.findAccessibleProjectBySlug(userId, slug);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const project = this.projectsRepository.create({
|
||||
ownerId: userId,
|
||||
slug,
|
||||
name: clientName.trim() || slug,
|
||||
});
|
||||
|
||||
const saved = await this.projectsRepository.save(project);
|
||||
await this.ensureMembership(saved.id, userId, 'owner');
|
||||
await this.recordActivity(saved.id, userId, 'project_created', {
|
||||
name: saved.name,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
async ensureMembership(projectId: string, userId: string, role: ProjectRole = 'editor') {
|
||||
const existing = await this.membersRepository.findOne({
|
||||
where: { projectId, userId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.role !== role) {
|
||||
existing.role = role;
|
||||
await this.membersRepository.save(existing);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
const member = this.membersRepository.create({
|
||||
projectId,
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
|
||||
return this.membersRepository.save(member);
|
||||
}
|
||||
|
||||
async isUserInProject(projectId: string, userId: string) {
|
||||
const project = await this.projectsRepository.findOne({
|
||||
where: { id: projectId },
|
||||
select: { ownerId: true, id: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
if (project.ownerId === userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await this.membersRepository.findOne({
|
||||
where: { projectId, userId },
|
||||
});
|
||||
|
||||
return Boolean(membership);
|
||||
}
|
||||
|
||||
async recordActivity(
|
||||
projectId: string,
|
||||
userId: string | null,
|
||||
action: ProjectActivityAction,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
const log = this.activityRepository.create({
|
||||
projectId,
|
||||
userId: userId ?? null,
|
||||
action,
|
||||
metadata: metadata ?? null,
|
||||
});
|
||||
|
||||
await this.activityRepository.save(log);
|
||||
await this.projectsRepository.update(projectId, {
|
||||
lastUpdatedById: userId ?? null,
|
||||
lastActivityAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async getActivitySummaries(
|
||||
userId: string,
|
||||
options?: { logLimit?: number },
|
||||
): Promise<Record<string, ProjectActivitySummaryDto>> {
|
||||
const projectIds = await this.getAccessibleProjectIds(userId);
|
||||
if (projectIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const logLimit = options?.logLimit ?? DEFAULT_LOG_LIMIT;
|
||||
|
||||
const projects = await this.projectsRepository.find({
|
||||
where: { id: In(projectIds) },
|
||||
relations: { lastUpdatedBy: true },
|
||||
});
|
||||
|
||||
const entries = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const logs = await this.activityRepository.find({
|
||||
where: { projectId: project.id },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: logLimit,
|
||||
relations: { user: true },
|
||||
});
|
||||
|
||||
return [project.slug, this.buildActivitySummary(project, logs)] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
return entries.reduce<Record<string, ProjectActivitySummaryDto>>((acc, [slug, summary]) => {
|
||||
acc[slug] = summary;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async getProjectActivity(
|
||||
userId: string,
|
||||
client: string,
|
||||
options?: { logLimit?: number },
|
||||
): Promise<ProjectActivitySummaryDto> {
|
||||
const project = await this.findAccessibleProjectBySlug(userId, client);
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
const fullProject = await this.projectsRepository.findOne({
|
||||
where: { id: project.id },
|
||||
relations: { lastUpdatedBy: true },
|
||||
});
|
||||
|
||||
if (!fullProject) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
const logs = await this.activityRepository.find({
|
||||
where: { projectId: project.id },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: options?.logLimit ?? 50,
|
||||
relations: { user: true },
|
||||
});
|
||||
|
||||
return this.buildActivitySummary(fullProject, logs);
|
||||
}
|
||||
|
||||
async getProjectCollaborators(projectId: string): Promise<User[]> {
|
||||
const [project, members] = await Promise.all([
|
||||
this.projectsRepository.findOne({
|
||||
where: { id: projectId },
|
||||
relations: { owner: true },
|
||||
}),
|
||||
this.membersRepository.find({
|
||||
where: { projectId },
|
||||
relations: { user: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
const collaborators = new Map<string, User>();
|
||||
|
||||
members.forEach((member) => {
|
||||
if (member.user) {
|
||||
collaborators.set(member.user.id, member.user);
|
||||
}
|
||||
});
|
||||
|
||||
if (project.owner) {
|
||||
collaborators.set(project.owner.id, project.owner);
|
||||
}
|
||||
|
||||
return Array.from(collaborators.values());
|
||||
}
|
||||
|
||||
private buildActivitySummary(project: Project, logs: ProjectActivityLog[]): ProjectActivitySummaryDto {
|
||||
return {
|
||||
projectId: project.id,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
lastUpdatedAt: project.lastActivityAt ? project.lastActivityAt.toISOString() : null,
|
||||
lastUpdatedBy: this.toActivityUser(project.lastUpdatedBy ?? null),
|
||||
recentLogs: logs.map((log) => this.toActivityLogDto(log)),
|
||||
};
|
||||
}
|
||||
|
||||
private toActivityLogDto(log: ProjectActivityLog): ProjectActivityLogDto {
|
||||
return {
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
metadata: log.metadata ?? null,
|
||||
user: this.toActivityUser(log.user ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
private toActivityUser(user: User | null): ProjectActivityUserDto | null {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, email, displayName, firstName, lastName } = user;
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
displayName: displayName ?? null,
|
||||
firstName: firstName ?? null,
|
||||
lastName: lastName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessibleOrganizations(userId: string): Promise<OrganizationSummaryDto[]> {
|
||||
const projectIds = await this.getAccessibleProjectIds(userId);
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projects = await this.projectsRepository.find({
|
||||
where: { id: In(projectIds) },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
|
||||
return projects.map((project) => this.toOrganizationSummary(project));
|
||||
}
|
||||
|
||||
async createOrganization(userId: string, dto: CreateOrganizationDto): Promise<OrganizationSummaryDto> {
|
||||
const slug = normalizeKey(dto.code);
|
||||
if (!slug) {
|
||||
throw new BadRequestException('Organization code is required.');
|
||||
}
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Organization name is required.');
|
||||
}
|
||||
|
||||
const existing = await this.findAccessibleProjectBySlug(userId, slug);
|
||||
if (existing) {
|
||||
throw new ConflictException('An organization with this code already exists.');
|
||||
}
|
||||
|
||||
const project = this.projectsRepository.create({
|
||||
ownerId: userId,
|
||||
slug,
|
||||
name,
|
||||
});
|
||||
|
||||
const saved = await this.projectsRepository.save(project);
|
||||
await this.ensureMembership(saved.id, userId, 'owner');
|
||||
await this.recordActivity(saved.id, userId, 'project_created', {
|
||||
name: saved.name,
|
||||
});
|
||||
|
||||
return this.toOrganizationSummary(saved);
|
||||
}
|
||||
|
||||
private toOrganizationSummary(project: Project): OrganizationSummaryDto {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
code: project.slug,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpsertReleaseDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
environment: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
branch: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
version: string;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
build: number;
|
||||
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
commitMessage?: string | null;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Project } from '../projects/project.entity';
|
||||
|
||||
@Entity('releases')
|
||||
@Index(['projectId', 'environment'], { unique: true })
|
||||
export class Release {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
client: string;
|
||||
|
||||
@Column()
|
||||
environment: string;
|
||||
|
||||
@Column()
|
||||
branch: string;
|
||||
|
||||
@Column()
|
||||
version: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
build: number;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
date: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
commitMessage: string | null;
|
||||
|
||||
@ManyToOne(() => Project, (project) => project.releases, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
project: Project;
|
||||
|
||||
@Column()
|
||||
projectId: string;
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../security/jwt-auth.guard';
|
||||
import { CurrentUser } from '../security/user.decorator';
|
||||
import type { JwtPayload } from '../security/jwt-payload.interface';
|
||||
import { UpsertReleaseDto } from './dto/upsert-release.dto';
|
||||
import { ReleasesService } from './releases.service';
|
||||
|
||||
@Controller('releases')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReleasesController {
|
||||
constructor(private readonly releasesService: ReleasesService) {}
|
||||
|
||||
@Get()
|
||||
getReleases(@CurrentUser() user: JwtPayload) {
|
||||
return this.releasesService.getReleasesForUser(user.sub);
|
||||
}
|
||||
|
||||
@Put(':client/:environment')
|
||||
upsertRelease(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('client') client: string,
|
||||
@Param('environment') environment: string,
|
||||
@Body() dto: UpsertReleaseDto,
|
||||
) {
|
||||
return this.releasesService.upsertRelease(user.sub, {
|
||||
...dto,
|
||||
client,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':client/:environment')
|
||||
deleteRelease(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('client') client: string,
|
||||
@Param('environment') environment: string,
|
||||
) {
|
||||
return this.releasesService.deleteRelease(user.sub, client, environment);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Release } from './release.entity';
|
||||
import { ReleasesService } from './releases.service';
|
||||
import { ReleasesController } from './releases.controller';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Release]), ProjectsModule, EmailModule, UsersModule],
|
||||
providers: [ReleasesService],
|
||||
controllers: [ReleasesController],
|
||||
})
|
||||
export class ReleasesModule {}
|
||||
@ -0,0 +1,295 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { normalizeKey } from '../shared/normalize-key';
|
||||
import { Release } from './release.entity';
|
||||
import { UpsertReleaseDto } from './dto/upsert-release.dto';
|
||||
import { ReleasePayload, ReleasesResponse } from './releases.types';
|
||||
import { ProjectsService } from '../projects/projects.service';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { formatReleaseDate } from 'src/utils/date.utils';
|
||||
|
||||
const RELEASE_CHANGE_FIELDS = ['branch', 'version', 'build', 'date', 'commitMessage'] as const;
|
||||
type ReleaseChangeField = (typeof RELEASE_CHANGE_FIELDS)[number];
|
||||
type ReleaseSnapshot = Pick<Release, 'branch' | 'version' | 'build' | 'date'> & { commitMessage: string | null };
|
||||
type ReleaseChangeValue = ReleaseSnapshot[ReleaseChangeField];
|
||||
|
||||
interface ReleaseChange {
|
||||
field: ReleaseChangeField;
|
||||
previous: ReleaseChangeValue;
|
||||
current: ReleaseChangeValue;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReleasesService {
|
||||
constructor(
|
||||
@InjectRepository(Release)
|
||||
private readonly releasesRepository: Repository<Release>,
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async getReleasesForUser(userId: string): Promise<ReleasesResponse> {
|
||||
const projectIds = await this.projectsService.getAccessibleProjectIds(userId);
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const releases = await this.releasesRepository.find({
|
||||
where: { projectId: In(projectIds) },
|
||||
order: {
|
||||
client: 'ASC',
|
||||
environment: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
return releases.reduce<ReleasesResponse>((acc, release) => {
|
||||
if (!acc[release.client]) {
|
||||
acc[release.client] = {};
|
||||
}
|
||||
|
||||
acc[release.client][release.environment] = this.buildPayload(release);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async upsertRelease(userId: string, dto: UpsertReleaseDto): Promise<ReleasesResponse> {
|
||||
const client = normalizeKey(dto.client);
|
||||
const environment = normalizeKey(dto.environment);
|
||||
|
||||
const project = await this.projectsService.ensureProjectForUser(userId, dto.client);
|
||||
const normalizedCommitMessage = this.normalizeCommitMessage(dto.commitMessage);
|
||||
const commitMessageProvided = dto.commitMessage !== undefined;
|
||||
|
||||
let release = await this.releasesRepository.findOne({
|
||||
where: { projectId: project.id, environment },
|
||||
});
|
||||
|
||||
const previousSnapshot = release ? this.captureReleaseSnapshot(release) : null;
|
||||
|
||||
const isNewRelease = !release;
|
||||
|
||||
if (!release) {
|
||||
release = this.releasesRepository.create({
|
||||
projectId: project.id,
|
||||
client,
|
||||
environment,
|
||||
branch: dto.branch,
|
||||
version: dto.version,
|
||||
build: dto.build,
|
||||
date: dto.date,
|
||||
commitMessage: commitMessageProvided ? normalizedCommitMessage : null,
|
||||
});
|
||||
} else {
|
||||
release.branch = dto.branch;
|
||||
release.version = dto.version;
|
||||
release.build = dto.build;
|
||||
release.date = dto.date;
|
||||
release.client = client;
|
||||
if (commitMessageProvided) {
|
||||
release.commitMessage = normalizedCommitMessage;
|
||||
}
|
||||
}
|
||||
|
||||
await this.releasesRepository.save(release);
|
||||
|
||||
const currentSnapshot = this.captureReleaseSnapshot(release);
|
||||
const changes = this.buildReleaseChanges(previousSnapshot, currentSnapshot);
|
||||
const metadata = this.buildReleaseActivityMetadata({
|
||||
environment,
|
||||
release,
|
||||
changes,
|
||||
isNewRelease,
|
||||
});
|
||||
|
||||
await this.projectsService.recordActivity(project.id, userId, 'release_upserted', metadata);
|
||||
await this.notifyCollaboratorsOfReleaseUpdate({
|
||||
project,
|
||||
release,
|
||||
actorId: userId,
|
||||
isNewRelease,
|
||||
});
|
||||
return this.getReleasesForUser(userId);
|
||||
}
|
||||
|
||||
async deleteRelease(
|
||||
userId: string,
|
||||
clientParam: string,
|
||||
envParam: string,
|
||||
): Promise<ReleasesResponse> {
|
||||
const client = normalizeKey(clientParam);
|
||||
const environment = normalizeKey(envParam);
|
||||
|
||||
const project = await this.projectsService.findAccessibleProjectBySlug(userId, client);
|
||||
|
||||
if (!project) {
|
||||
throw new ForbiddenException('You do not have access to this project');
|
||||
}
|
||||
|
||||
const release = await this.releasesRepository.findOne({
|
||||
where: { projectId: project.id, environment },
|
||||
});
|
||||
|
||||
if (!release) {
|
||||
throw new NotFoundException('Release not found');
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
environment,
|
||||
releaseId: release.id,
|
||||
branch: release.branch,
|
||||
version: release.version,
|
||||
build: release.build,
|
||||
date: release.date,
|
||||
commitMessage: release.commitMessage,
|
||||
};
|
||||
|
||||
await this.releasesRepository.remove(release);
|
||||
await this.projectsService.recordActivity(project.id, userId, 'release_deleted', metadata);
|
||||
return this.getReleasesForUser(userId);
|
||||
}
|
||||
|
||||
private buildPayload(release: Release): ReleasePayload {
|
||||
return {
|
||||
branch: release.branch,
|
||||
version: release.version,
|
||||
build: release.build,
|
||||
date: release.date,
|
||||
commitMessage: release.commitMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCommitMessage(value?: string | null): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
private async notifyCollaboratorsOfReleaseUpdate(options: {
|
||||
project: { id: string; name: string; slug: string };
|
||||
release: Release;
|
||||
actorId: string;
|
||||
isNewRelease: boolean;
|
||||
}) {
|
||||
const collaborators = await this.projectsService.getProjectCollaborators(options.project.id);
|
||||
|
||||
if (!collaborators.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipients = collaborators.filter((user) => user.id !== options.actorId);
|
||||
|
||||
if (!recipients.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actor =
|
||||
collaborators.find((user) => user.id === options.actorId) ??
|
||||
(await this.usersService.findById(options.actorId));
|
||||
|
||||
const actorName = this.buildUserDisplayName(actor);
|
||||
const actorEmail = actor?.email ?? 'unknown';
|
||||
const releaseDate = formatReleaseDate(options.release.date) ?? '';
|
||||
|
||||
await Promise.all(
|
||||
recipients.map((recipient) =>
|
||||
this.emailService.sendReleaseUpdateNotification(recipient.email, {
|
||||
projectName: options.project.name,
|
||||
projectSlug: options.project.slug,
|
||||
environment: options.release.environment,
|
||||
version: options.release.version,
|
||||
branch: options.release.branch,
|
||||
build: options.release.build,
|
||||
date: releaseDate,
|
||||
commitMessage: options.release.commitMessage ?? null,
|
||||
actorName,
|
||||
actorEmail,
|
||||
isNewRelease: options.isNewRelease,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private buildUserDisplayName(user: User | null | undefined) {
|
||||
if (!user) {
|
||||
return 'A collaborator';
|
||||
}
|
||||
|
||||
if (user.displayName) {
|
||||
return user.displayName;
|
||||
}
|
||||
|
||||
const nameParts = [user.firstName, user.lastName].filter((part): part is string =>
|
||||
Boolean(part && part.trim()),
|
||||
);
|
||||
|
||||
if (nameParts.length) {
|
||||
return nameParts.join(' ');
|
||||
}
|
||||
|
||||
return user.email;
|
||||
}
|
||||
|
||||
private captureReleaseSnapshot(release: Release): ReleaseSnapshot {
|
||||
return {
|
||||
branch: release.branch,
|
||||
version: release.version,
|
||||
build: release.build,
|
||||
date: release.date,
|
||||
commitMessage: release.commitMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildReleaseChanges(previous: ReleaseSnapshot | null, current: ReleaseSnapshot): ReleaseChange[] {
|
||||
if (!previous) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return RELEASE_CHANGE_FIELDS.map((field) => {
|
||||
const before = previous[field];
|
||||
const after = current[field];
|
||||
|
||||
if (before === after) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
field,
|
||||
previous: before ?? null,
|
||||
current: after ?? null,
|
||||
};
|
||||
}).filter((change): change is ReleaseChange => Boolean(change));
|
||||
}
|
||||
|
||||
private buildReleaseActivityMetadata(options: {
|
||||
environment: string;
|
||||
release: Release;
|
||||
changes: ReleaseChange[];
|
||||
isNewRelease: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {
|
||||
client: options.release.client,
|
||||
environment: options.environment,
|
||||
releaseId: options.release.id,
|
||||
branch: options.release.branch,
|
||||
version: options.release.version,
|
||||
build: options.release.build,
|
||||
date: options.release.date,
|
||||
commitMessage: options.release.commitMessage ?? null,
|
||||
isNewRelease: options.isNewRelease,
|
||||
};
|
||||
|
||||
if (options.changes.length > 0) {
|
||||
metadata.changes = options.changes;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export interface ReleasePayload {
|
||||
branch: string;
|
||||
version: string;
|
||||
build: number;
|
||||
date: string;
|
||||
commitMessage: string | null;
|
||||
}
|
||||
|
||||
export type ReleasesResponse = Record<string, Record<string, ReleasePayload>>;
|
||||
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@ -0,0 +1,6 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from './jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET', 'super-secret'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtPayload } from './jwt-payload.interface';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): JwtPayload => {
|
||||
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
|
||||
if (!request.user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@ -0,0 +1 @@
|
||||
export const normalizeKey = (value: string) => value.trim().toLowerCase();
|
||||
@ -0,0 +1,36 @@
|
||||
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { TransactionCode } from './transaction-code.entity';
|
||||
|
||||
@Entity('charge_codes')
|
||||
export class ChargeCode {
|
||||
@PrimaryColumn()
|
||||
pch_chrgcode: string;
|
||||
|
||||
@Column()
|
||||
pch_chrgdesc: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
pch_chrgshort: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
pel_elmtcode: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ptr_trancode: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
pch_chrgprofit: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
soc_charges: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => TransactionCode, transactionCode => transactionCode.chargeCodes)
|
||||
@JoinColumn({ name: 'ptr_trancode' })
|
||||
transactionCode: TransactionCode;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Unique } from 'typeorm';
|
||||
|
||||
@Entity('client_charge_mapped')
|
||||
@Unique(['orgacode', 'pet_eventcode', 'ptr_trancode'])
|
||||
export class ClientChargeMapped {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
orgacode: string;
|
||||
|
||||
@Column()
|
||||
pet_eventcode: string;
|
||||
|
||||
@Column()
|
||||
ptr_trancode: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { IsString, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateChargeCodeDto {
|
||||
@IsString()
|
||||
pch_chrgcode: string;
|
||||
|
||||
@IsString()
|
||||
pch_chrgdesc: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pch_chrgshort?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pel_elmtcode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ptr_trancode?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
pch_chrgprofit?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
soc_charges?: string;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class CreateClientChargeMappedDto {
|
||||
@IsString()
|
||||
orgacode: string;
|
||||
|
||||
@IsString()
|
||||
pet_eventcode: string;
|
||||
|
||||
@IsString()
|
||||
ptr_trancode: string;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { IsString, IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateTransactionCodeDto {
|
||||
@IsString()
|
||||
ptr_trancode: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pet_eventcode?: string;
|
||||
|
||||
@IsString()
|
||||
ptr_trandesc: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
system_generated?: boolean;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { IsString, IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateTransactionEventMasterDto {
|
||||
@IsString()
|
||||
pet_eventcode: string;
|
||||
|
||||
@IsString()
|
||||
pet_eventdesc: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
system_generated?: boolean;
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { IsString, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateChargeCodeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pch_chrgdesc?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pch_chrgshort?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pel_elmtcode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ptr_trancode?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
pch_chrgprofit?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
soc_charges?: string;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateClientChargeMappedDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
orgacode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pet_eventcode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ptr_trancode?: string;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { IsString, IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateTransactionCodeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pet_eventcode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ptr_trandesc?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
system_generated?: boolean;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { IsString, IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateTransactionEventMasterDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pet_eventdesc?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
system_generated?: boolean;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
|
||||
import { TransactionEventMaster } from './transaction-event-master.entity';
|
||||
import { ChargeCode } from './charge-code.entity';
|
||||
|
||||
@Entity('transaction_codes')
|
||||
export class TransactionCode {
|
||||
@PrimaryColumn()
|
||||
ptr_trancode: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
pet_eventcode: string;
|
||||
|
||||
@Column()
|
||||
ptr_trandesc: string;
|
||||
|
||||
@Column({ default: false })
|
||||
system_generated: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => TransactionEventMaster, transactionEvent => transactionEvent.transactionCodes)
|
||||
@JoinColumn({ name: 'pet_eventcode' })
|
||||
transactionEvent: TransactionEventMaster;
|
||||
|
||||
@OneToMany(() => ChargeCode, chargeCode => chargeCode.transactionCode)
|
||||
chargeCodes: ChargeCode[];
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../security/jwt-auth.guard';
|
||||
import { TransactionCodesService } from './transaction-codes.service';
|
||||
import { CreateTransactionEventMasterDto } from './dto/create-transaction-event-master.dto';
|
||||
import { UpdateTransactionEventMasterDto } from './dto/update-transaction-event-master.dto';
|
||||
import { CreateTransactionCodeDto } from './dto/create-transaction-code.dto';
|
||||
import { UpdateTransactionCodeDto } from './dto/update-transaction-code.dto';
|
||||
import { CreateChargeCodeDto } from './dto/create-charge-code.dto';
|
||||
import { UpdateChargeCodeDto } from './dto/update-charge-code.dto';
|
||||
import { CreateClientChargeMappedDto } from './dto/create-client-charge-mapped.dto';
|
||||
import { UpdateClientChargeMappedDto } from './dto/update-client-charge-mapped.dto';
|
||||
|
||||
@Controller('api')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TransactionCodesController {
|
||||
constructor(private readonly transactionCodesService: TransactionCodesService) {}
|
||||
|
||||
@Post('transaction-events')
|
||||
createTransactionEvent(@Body() dto: CreateTransactionEventMasterDto) {
|
||||
return this.transactionCodesService.createTransactionEvent(dto);
|
||||
}
|
||||
|
||||
@Get('transaction-events')
|
||||
findAllTransactionEvents() {
|
||||
return this.transactionCodesService.findAllTransactionEvents();
|
||||
}
|
||||
|
||||
@Get('transaction-events/:pet_eventcode')
|
||||
findOneTransactionEvent(@Param('pet_eventcode') pet_eventcode: string) {
|
||||
return this.transactionCodesService.findOneTransactionEvent(pet_eventcode);
|
||||
}
|
||||
|
||||
@Put('transaction-events/:pet_eventcode')
|
||||
updateTransactionEvent(
|
||||
@Param('pet_eventcode') pet_eventcode: string,
|
||||
@Body() dto: UpdateTransactionEventMasterDto,
|
||||
) {
|
||||
return this.transactionCodesService.updateTransactionEvent(pet_eventcode, dto);
|
||||
}
|
||||
|
||||
@Delete('transaction-events/:pet_eventcode')
|
||||
deleteTransactionEvent(@Param('pet_eventcode') pet_eventcode: string) {
|
||||
return this.transactionCodesService.deleteTransactionEvent(pet_eventcode);
|
||||
}
|
||||
|
||||
@Post('transaction-codes')
|
||||
createTransactionCode(@Body() dto: CreateTransactionCodeDto) {
|
||||
return this.transactionCodesService.createTransactionCode(dto);
|
||||
}
|
||||
|
||||
@Get('transaction-codes')
|
||||
findAllTransactionCodes() {
|
||||
return this.transactionCodesService.findAllTransactionCodes();
|
||||
}
|
||||
|
||||
@Get('transaction-codes/:ptr_trancode')
|
||||
findOneTransactionCode(@Param('ptr_trancode') ptr_trancode: string) {
|
||||
return this.transactionCodesService.findOneTransactionCode(ptr_trancode);
|
||||
}
|
||||
|
||||
@Put('transaction-codes/:ptr_trancode')
|
||||
updateTransactionCode(
|
||||
@Param('ptr_trancode') ptr_trancode: string,
|
||||
@Body() dto: UpdateTransactionCodeDto,
|
||||
) {
|
||||
return this.transactionCodesService.updateTransactionCode(ptr_trancode, dto);
|
||||
}
|
||||
|
||||
@Delete('transaction-codes/:ptr_trancode')
|
||||
deleteTransactionCode(@Param('ptr_trancode') ptr_trancode: string) {
|
||||
return this.transactionCodesService.deleteTransactionCode(ptr_trancode);
|
||||
}
|
||||
|
||||
@Post('charge-codes')
|
||||
createChargeCode(@Body() dto: CreateChargeCodeDto) {
|
||||
return this.transactionCodesService.createChargeCode(dto);
|
||||
}
|
||||
|
||||
@Get('charge-codes')
|
||||
findAllChargeCodes() {
|
||||
return this.transactionCodesService.findAllChargeCodes();
|
||||
}
|
||||
|
||||
@Get('charge-codes/:pch_chrgcode')
|
||||
findOneChargeCode(@Param('pch_chrgcode') pch_chrgcode: string) {
|
||||
return this.transactionCodesService.findOneChargeCode(pch_chrgcode);
|
||||
}
|
||||
|
||||
@Put('charge-codes/:pch_chrgcode')
|
||||
updateChargeCode(
|
||||
@Param('pch_chrgcode') pch_chrgcode: string,
|
||||
@Body() dto: UpdateChargeCodeDto,
|
||||
) {
|
||||
return this.transactionCodesService.updateChargeCode(pch_chrgcode, dto);
|
||||
}
|
||||
|
||||
@Delete('charge-codes/:pch_chrgcode')
|
||||
deleteChargeCode(@Param('pch_chrgcode') pch_chrgcode: string) {
|
||||
return this.transactionCodesService.deleteChargeCode(pch_chrgcode);
|
||||
}
|
||||
|
||||
@Post('client-charge-mappings')
|
||||
createClientChargeMapping(@Body() dto: CreateClientChargeMappedDto) {
|
||||
return this.transactionCodesService.createClientChargeMapping(dto);
|
||||
}
|
||||
|
||||
@Get('client-charge-mappings')
|
||||
findAllClientChargeMappings() {
|
||||
return this.transactionCodesService.findAllClientChargeMappings();
|
||||
}
|
||||
|
||||
@Get('client-charge-mappings/:id')
|
||||
findOneClientChargeMapping(@Param('id') id: string) {
|
||||
return this.transactionCodesService.findOneClientChargeMapping(id);
|
||||
}
|
||||
|
||||
@Put('client-charge-mappings/:id')
|
||||
updateClientChargeMapping(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClientChargeMappedDto,
|
||||
) {
|
||||
return this.transactionCodesService.updateClientChargeMapping(id, dto);
|
||||
}
|
||||
|
||||
@Delete('client-charge-mappings/:id')
|
||||
deleteClientChargeMapping(@Param('id') id: string) {
|
||||
return this.transactionCodesService.deleteClientChargeMapping(id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TransactionCodesController } from './transaction-codes.controller';
|
||||
import { TransactionCodesService } from './transaction-codes.service';
|
||||
import { TransactionEventMaster } from './transaction-event-master.entity';
|
||||
import { TransactionCode } from './transaction-code.entity';
|
||||
import { ChargeCode } from './charge-code.entity';
|
||||
import { ClientChargeMapped } from './client-charge-mapped.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TransactionEventMaster,
|
||||
TransactionCode,
|
||||
ChargeCode,
|
||||
ClientChargeMapped,
|
||||
]),
|
||||
],
|
||||
controllers: [TransactionCodesController],
|
||||
providers: [TransactionCodesService],
|
||||
exports: [TransactionCodesService],
|
||||
})
|
||||
export class TransactionCodesModule {}
|
||||
@ -0,0 +1,232 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TransactionEventMaster } from './transaction-event-master.entity';
|
||||
import { TransactionCode } from './transaction-code.entity';
|
||||
import { ChargeCode } from './charge-code.entity';
|
||||
import { ClientChargeMapped } from './client-charge-mapped.entity';
|
||||
import { CreateTransactionEventMasterDto } from './dto/create-transaction-event-master.dto';
|
||||
import { UpdateTransactionEventMasterDto } from './dto/update-transaction-event-master.dto';
|
||||
import { CreateTransactionCodeDto } from './dto/create-transaction-code.dto';
|
||||
import { UpdateTransactionCodeDto } from './dto/update-transaction-code.dto';
|
||||
import { CreateChargeCodeDto } from './dto/create-charge-code.dto';
|
||||
import { UpdateChargeCodeDto } from './dto/update-charge-code.dto';
|
||||
import { CreateClientChargeMappedDto } from './dto/create-client-charge-mapped.dto';
|
||||
import { UpdateClientChargeMappedDto } from './dto/update-client-charge-mapped.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TransactionCodesService {
|
||||
constructor(
|
||||
@InjectRepository(TransactionEventMaster)
|
||||
private transactionEventMasterRepository: Repository<TransactionEventMaster>,
|
||||
@InjectRepository(TransactionCode)
|
||||
private transactionCodeRepository: Repository<TransactionCode>,
|
||||
@InjectRepository(ChargeCode)
|
||||
private chargeCodeRepository: Repository<ChargeCode>,
|
||||
@InjectRepository(ClientChargeMapped)
|
||||
private clientChargeMappedRepository: Repository<ClientChargeMapped>,
|
||||
) {}
|
||||
|
||||
async createTransactionEvent(dto: CreateTransactionEventMasterDto): Promise<TransactionEventMaster> {
|
||||
const existing = await this.transactionEventMasterRepository.findOne({
|
||||
where: { pet_eventcode: dto.pet_eventcode },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Transaction event code already exists');
|
||||
}
|
||||
|
||||
const transactionEvent = this.transactionEventMasterRepository.create(dto);
|
||||
return this.transactionEventMasterRepository.save(transactionEvent);
|
||||
}
|
||||
|
||||
async findAllTransactionEvents(): Promise<TransactionEventMaster[]> {
|
||||
return this.transactionEventMasterRepository.find({
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneTransactionEvent(pet_eventcode: string): Promise<TransactionEventMaster> {
|
||||
const transactionEvent = await this.transactionEventMasterRepository.findOne({
|
||||
where: { pet_eventcode },
|
||||
});
|
||||
|
||||
if (!transactionEvent) {
|
||||
throw new NotFoundException('Transaction event not found');
|
||||
}
|
||||
|
||||
return transactionEvent;
|
||||
}
|
||||
|
||||
async updateTransactionEvent(
|
||||
pet_eventcode: string,
|
||||
dto: UpdateTransactionEventMasterDto,
|
||||
): Promise<TransactionEventMaster> {
|
||||
const transactionEvent = await this.findOneTransactionEvent(pet_eventcode);
|
||||
Object.assign(transactionEvent, dto);
|
||||
return this.transactionEventMasterRepository.save(transactionEvent);
|
||||
}
|
||||
|
||||
async deleteTransactionEvent(pet_eventcode: string): Promise<void> {
|
||||
const transactionEvent = await this.findOneTransactionEvent(pet_eventcode);
|
||||
await this.transactionEventMasterRepository.remove(transactionEvent);
|
||||
}
|
||||
|
||||
async createTransactionCode(dto: CreateTransactionCodeDto): Promise<TransactionCode> {
|
||||
const existing = await this.transactionCodeRepository.findOne({
|
||||
where: { ptr_trancode: dto.ptr_trancode },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Transaction code already exists');
|
||||
}
|
||||
|
||||
const transactionCode = this.transactionCodeRepository.create(dto);
|
||||
return this.transactionCodeRepository.save(transactionCode);
|
||||
}
|
||||
|
||||
async findAllTransactionCodes(): Promise<TransactionCode[]> {
|
||||
return this.transactionCodeRepository.find({
|
||||
relations: ['transactionEvent'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneTransactionCode(ptr_trancode: string): Promise<TransactionCode> {
|
||||
const transactionCode = await this.transactionCodeRepository.findOne({
|
||||
where: { ptr_trancode },
|
||||
relations: ['transactionEvent'],
|
||||
});
|
||||
|
||||
if (!transactionCode) {
|
||||
throw new NotFoundException('Transaction code not found');
|
||||
}
|
||||
|
||||
return transactionCode;
|
||||
}
|
||||
|
||||
async updateTransactionCode(
|
||||
ptr_trancode: string,
|
||||
dto: UpdateTransactionCodeDto,
|
||||
): Promise<TransactionCode> {
|
||||
const transactionCode = await this.findOneTransactionCode(ptr_trancode);
|
||||
Object.assign(transactionCode, dto);
|
||||
return this.transactionCodeRepository.save(transactionCode);
|
||||
}
|
||||
|
||||
async deleteTransactionCode(ptr_trancode: string): Promise<void> {
|
||||
const transactionCode = await this.findOneTransactionCode(ptr_trancode);
|
||||
await this.transactionCodeRepository.remove(transactionCode);
|
||||
}
|
||||
|
||||
async createChargeCode(dto: CreateChargeCodeDto): Promise<ChargeCode> {
|
||||
const existing = await this.chargeCodeRepository.findOne({
|
||||
where: { pch_chrgcode: dto.pch_chrgcode },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Charge code already exists');
|
||||
}
|
||||
|
||||
const chargeCode = this.chargeCodeRepository.create(dto);
|
||||
return this.chargeCodeRepository.save(chargeCode);
|
||||
}
|
||||
|
||||
async findAllChargeCodes(): Promise<ChargeCode[]> {
|
||||
return this.chargeCodeRepository.find({
|
||||
relations: ['transactionCode'],
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneChargeCode(pch_chrgcode: string): Promise<ChargeCode> {
|
||||
const chargeCode = await this.chargeCodeRepository.findOne({
|
||||
where: { pch_chrgcode },
|
||||
relations: ['transactionCode'],
|
||||
});
|
||||
|
||||
if (!chargeCode) {
|
||||
throw new NotFoundException('Charge code not found');
|
||||
}
|
||||
|
||||
return chargeCode;
|
||||
}
|
||||
|
||||
async updateChargeCode(
|
||||
pch_chrgcode: string,
|
||||
dto: UpdateChargeCodeDto,
|
||||
): Promise<ChargeCode> {
|
||||
const chargeCode = await this.findOneChargeCode(pch_chrgcode);
|
||||
Object.assign(chargeCode, dto);
|
||||
return this.chargeCodeRepository.save(chargeCode);
|
||||
}
|
||||
|
||||
async deleteChargeCode(pch_chrgcode: string): Promise<void> {
|
||||
const chargeCode = await this.findOneChargeCode(pch_chrgcode);
|
||||
await this.chargeCodeRepository.remove(chargeCode);
|
||||
}
|
||||
|
||||
async createClientChargeMapping(dto: CreateClientChargeMappedDto): Promise<ClientChargeMapped> {
|
||||
const existing = await this.clientChargeMappedRepository.findOne({
|
||||
where: {
|
||||
orgacode: dto.orgacode,
|
||||
pet_eventcode: dto.pet_eventcode,
|
||||
ptr_trancode: dto.ptr_trancode,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`This code is already exist with client ${dto.orgacode}`);
|
||||
}
|
||||
|
||||
const mapping = this.clientChargeMappedRepository.create(dto);
|
||||
return this.clientChargeMappedRepository.save(mapping);
|
||||
}
|
||||
|
||||
async findAllClientChargeMappings(): Promise<ClientChargeMapped[]> {
|
||||
return this.clientChargeMappedRepository.find({
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneClientChargeMapping(id: string): Promise<ClientChargeMapped> {
|
||||
const mapping = await this.clientChargeMappedRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
throw new NotFoundException('Client charge mapping not found');
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
async updateClientChargeMapping(
|
||||
id: string,
|
||||
dto: UpdateClientChargeMappedDto,
|
||||
): Promise<ClientChargeMapped> {
|
||||
const mapping = await this.findOneClientChargeMapping(id);
|
||||
|
||||
if (dto.orgacode || dto.pet_eventcode || dto.ptr_trancode) {
|
||||
const existing = await this.clientChargeMappedRepository.findOne({
|
||||
where: {
|
||||
orgacode: dto.orgacode ?? mapping.orgacode,
|
||||
pet_eventcode: dto.pet_eventcode ?? mapping.pet_eventcode,
|
||||
ptr_trancode: dto.ptr_trancode ?? mapping.ptr_trancode,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing && existing.id !== id) {
|
||||
throw new ConflictException(`This code is already exist with client ${dto.orgacode ?? mapping.orgacode}`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(mapping, dto);
|
||||
return this.clientChargeMappedRepository.save(mapping);
|
||||
}
|
||||
|
||||
async deleteClientChargeMapping(id: string): Promise<void> {
|
||||
const mapping = await this.findOneClientChargeMapping(id);
|
||||
await this.clientChargeMappedRepository.remove(mapping);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||
import { TransactionCode } from './transaction-code.entity';
|
||||
|
||||
@Entity('transaction_events_master')
|
||||
export class TransactionEventMaster {
|
||||
@PrimaryColumn()
|
||||
pet_eventcode: string;
|
||||
|
||||
@Column()
|
||||
pet_eventdesc: string;
|
||||
|
||||
@Column({ default: false })
|
||||
system_generated: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@OneToMany(() => TransactionCode, transactionCode => transactionCode.transactionEvent)
|
||||
transactionCodes: TransactionCode[];
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateTransactionEventDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
petEventCode: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
petEventDesc: string;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateTransactionEventDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
petEventCode: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
petEventDesc: string;
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Project } from '../projects/project.entity';
|
||||
|
||||
@Entity('transaction_events')
|
||||
@Index(['codeKey'], { unique: true })
|
||||
export class TransactionEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'code' })
|
||||
petEventCode: string;
|
||||
|
||||
@Column()
|
||||
codeKey: string;
|
||||
|
||||
@Column({ type: 'text', name: 'description' })
|
||||
petEventDesc: string;
|
||||
|
||||
@ManyToOne(() => Project, (project) => project.transactionEvents, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
project: Project;
|
||||
|
||||
@Column()
|
||||
projectId: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { TransactionEventsService } from './transaction-events.service';
|
||||
import { JwtAuthGuard } from '../security/jwt-auth.guard';
|
||||
import { CurrentUser } from '../security/user.decorator';
|
||||
import type { JwtPayload } from '../security/jwt-payload.interface';
|
||||
import { CreateTransactionEventDto } from './dto/create-transaction-event.dto';
|
||||
import { UpdateTransactionEventDto } from './dto/update-transaction-event.dto';
|
||||
|
||||
@Controller('transaction-events')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TransactionEventsController {
|
||||
constructor(private readonly transactionEventsService: TransactionEventsService) {}
|
||||
|
||||
@Get()
|
||||
getTransactionEvents(@CurrentUser() user: JwtPayload) {
|
||||
return this.transactionEventsService.getEventsForUser(user.sub);
|
||||
}
|
||||
|
||||
@Post()
|
||||
createTransactionEvent(@CurrentUser() user: JwtPayload, @Body() dto: CreateTransactionEventDto) {
|
||||
return this.transactionEventsService.createTransactionEvent(user.sub, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
updateTransactionEvent(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') eventId: string,
|
||||
@Body() dto: UpdateTransactionEventDto,
|
||||
) {
|
||||
return this.transactionEventsService.updateTransactionEvent(user.sub, eventId, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TransactionEvent } from './transaction-event.entity';
|
||||
import { TransactionEventsService } from './transaction-events.service';
|
||||
import { TransactionEventsController } from './transaction-events.controller';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TransactionEvent]), ProjectsModule],
|
||||
providers: [TransactionEventsService],
|
||||
controllers: [TransactionEventsController],
|
||||
})
|
||||
export class TransactionEventsModule {}
|
||||
@ -0,0 +1,151 @@
|
||||
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { TransactionEvent } from './transaction-event.entity';
|
||||
import { ProjectsService } from '../projects/projects.service';
|
||||
import { CreateTransactionEventDto } from './dto/create-transaction-event.dto';
|
||||
import { normalizeKey } from '../shared/normalize-key';
|
||||
import { TransactionEventPayload, TransactionEventsResponse } from './transaction-events.types';
|
||||
import { UpdateTransactionEventDto } from './dto/update-transaction-event.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TransactionEventsService {
|
||||
constructor(
|
||||
@InjectRepository(TransactionEvent)
|
||||
private readonly transactionEventsRepository: Repository<TransactionEvent>,
|
||||
private readonly projectsService: ProjectsService,
|
||||
) {}
|
||||
|
||||
async getEventsForUser(userId: string): Promise<TransactionEventsResponse> {
|
||||
const projectIds = await this.projectsService.getAccessibleProjectIds(userId);
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const events = await this.transactionEventsRepository.find({
|
||||
where: { projectId: In(projectIds) },
|
||||
relations: { project: true },
|
||||
order: { petEventCode: 'ASC' },
|
||||
});
|
||||
|
||||
return events.reduce<TransactionEventsResponse>((acc, event) => {
|
||||
if (!event.project) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const client = event.project.slug;
|
||||
if (!acc[client]) {
|
||||
acc[client] = [];
|
||||
}
|
||||
|
||||
acc[client].push(this.mapToPayload(event));
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async createTransactionEvent(userId: string, dto: CreateTransactionEventDto): Promise<TransactionEventsResponse> {
|
||||
const normalizedClient = normalizeKey(dto.client);
|
||||
const normalizedCode = normalizeKey(dto.petEventCode);
|
||||
|
||||
const existing = await this.transactionEventsRepository.findOne({
|
||||
where: { codeKey: normalizedCode },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Transaction event already exists');
|
||||
}
|
||||
|
||||
const project = await this.projectsService.findAccessibleProjectBySlug(userId, normalizedClient);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
const event = this.transactionEventsRepository.create({
|
||||
projectId: project.id,
|
||||
petEventCode: dto.petEventCode.trim(),
|
||||
codeKey: normalizedCode,
|
||||
petEventDesc: dto.petEventDesc.trim(),
|
||||
});
|
||||
|
||||
await this.transactionEventsRepository.save(event);
|
||||
|
||||
await this.projectsService.recordActivity(project.id, userId, 'transaction_event_created', {
|
||||
transactionId: event.id,
|
||||
petEventCode: event.petEventCode,
|
||||
});
|
||||
|
||||
return this.getEventsForUser(userId);
|
||||
}
|
||||
|
||||
async updateTransactionEvent(
|
||||
userId: string,
|
||||
eventId: string,
|
||||
dto: UpdateTransactionEventDto,
|
||||
): Promise<TransactionEventsResponse> {
|
||||
const event = await this.transactionEventsRepository.findOne({
|
||||
where: { id: eventId },
|
||||
relations: { project: true },
|
||||
});
|
||||
|
||||
if (!event || !event.project) {
|
||||
throw new NotFoundException('Transaction event not found');
|
||||
}
|
||||
|
||||
const canEdit = await this.projectsService.isUserInProject(event.projectId, userId);
|
||||
if (!canEdit) {
|
||||
throw new ForbiddenException('You do not have access to this transaction event');
|
||||
}
|
||||
|
||||
const normalizedCode = normalizeKey(dto.petEventCode);
|
||||
if (normalizedCode !== event.codeKey) {
|
||||
const conflict = await this.transactionEventsRepository.findOne({
|
||||
where: { codeKey: normalizedCode },
|
||||
});
|
||||
|
||||
if (conflict && conflict.id !== event.id) {
|
||||
throw new ConflictException('Transaction event already exists');
|
||||
}
|
||||
|
||||
event.codeKey = normalizedCode;
|
||||
}
|
||||
|
||||
const normalizedClient = normalizeKey(dto.client);
|
||||
if (normalizedClient !== event.project.slug) {
|
||||
const targetProject = await this.projectsService.findAccessibleProjectBySlug(userId, normalizedClient);
|
||||
|
||||
if (!targetProject) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
event.projectId = targetProject.id;
|
||||
event.project = targetProject;
|
||||
}
|
||||
|
||||
event.petEventCode = dto.petEventCode.trim();
|
||||
event.petEventDesc = dto.petEventDesc.trim();
|
||||
|
||||
await this.transactionEventsRepository.save(event);
|
||||
|
||||
await this.projectsService.recordActivity(event.projectId, userId, 'transaction_event_updated', {
|
||||
transactionId: event.id,
|
||||
petEventCode: event.petEventCode,
|
||||
});
|
||||
|
||||
return this.getEventsForUser(userId);
|
||||
}
|
||||
|
||||
private mapToPayload(event: TransactionEvent): TransactionEventPayload {
|
||||
return {
|
||||
id: event.id,
|
||||
client: event.project.slug,
|
||||
projectId: event.projectId,
|
||||
projectName: event.project.name,
|
||||
petEventCode: event.petEventCode,
|
||||
petEventDesc: event.petEventDesc,
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
updatedAt: event.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export interface TransactionEventPayload {
|
||||
id: string;
|
||||
client: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
petEventCode: string;
|
||||
petEventDesc: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type TransactionEventsResponse = Record<string, TransactionEventPayload[]>;
|
||||
@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdatePasswordDto {
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
displayName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
firstName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
lastName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
jobTitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
location?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
bio?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phoneNumber?: string | null;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Project } from '../projects/project.entity';
|
||||
import { ProjectMember } from '../projects/project-member.entity';
|
||||
import { ProjectActivityLog } from '../projects/project-activity-log.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 120 })
|
||||
firstName: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 120 })
|
||||
lastName: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 120 })
|
||||
displayName: string | null;
|
||||
|
||||
@Column({ type: 'longtext', nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
jobTitle: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 120 })
|
||||
location: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
bio: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 40 })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => Project, (project) => project.owner)
|
||||
ownedProjects: Project[];
|
||||
|
||||
@OneToMany(() => ProjectMember, (member) => member.user)
|
||||
projectMemberships: ProjectMember[];
|
||||
|
||||
@OneToMany(() => ProjectActivityLog, (log) => log.user)
|
||||
projectActivityLogs: ProjectActivityLog[];
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { JwtAuthGuard } from '../security/jwt-auth.guard';
|
||||
import { CurrentUser } from '../security/user.decorator';
|
||||
import type { JwtPayload } from '../security/jwt-payload.interface';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { UpdatePasswordDto } from './dto/update-password.dto';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
getProfile(@CurrentUser() user: JwtPayload) {
|
||||
return this.usersService.getProfileById(user.sub);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||
return this.usersService.updateProfile(user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('me/password')
|
||||
updatePassword(@CurrentUser() user: JwtPayload, @Body() dto: UpdatePasswordDto) {
|
||||
return this.usersService.updatePassword(user.sub, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@ -0,0 +1,168 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { UpdatePasswordDto } from './dto/update-password.dto';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
jobTitle: string | null;
|
||||
location: string | null;
|
||||
bio: string | null;
|
||||
phoneNumber: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
findByEmail(email: string) {
|
||||
return this.usersRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async create(
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
options?: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
},
|
||||
) {
|
||||
const normalizedFirstName = this.normalizeString(options?.firstName ?? null);
|
||||
const normalizedLastName = this.normalizeString(options?.lastName ?? null);
|
||||
const defaultDisplayName = this.buildDisplayName(normalizedFirstName, normalizedLastName, email);
|
||||
const user = this.usersRepository.create({
|
||||
email,
|
||||
passwordHash,
|
||||
firstName: normalizedFirstName ?? null,
|
||||
lastName: normalizedLastName ?? null,
|
||||
displayName: defaultDisplayName,
|
||||
});
|
||||
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async getProfileById(userId: string) {
|
||||
const user = await this.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return this.toProfile(user);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto) {
|
||||
const user = await this.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (dto.displayName !== undefined) {
|
||||
user.displayName = this.normalizeString(dto.displayName);
|
||||
}
|
||||
|
||||
if (dto.firstName !== undefined) {
|
||||
user.firstName = this.normalizeString(dto.firstName);
|
||||
}
|
||||
|
||||
if (dto.lastName !== undefined) {
|
||||
user.lastName = this.normalizeString(dto.lastName);
|
||||
}
|
||||
|
||||
if (dto.avatarUrl !== undefined) {
|
||||
user.avatarUrl = dto.avatarUrl ? dto.avatarUrl.trim() : null;
|
||||
}
|
||||
|
||||
if (dto.jobTitle !== undefined) {
|
||||
user.jobTitle = this.normalizeString(dto.jobTitle);
|
||||
}
|
||||
|
||||
if (dto.location !== undefined) {
|
||||
user.location = this.normalizeString(dto.location);
|
||||
}
|
||||
|
||||
if (dto.bio !== undefined) {
|
||||
user.bio = dto.bio ? dto.bio.trim() : null;
|
||||
}
|
||||
|
||||
if (dto.phoneNumber !== undefined) {
|
||||
user.phoneNumber = this.normalizeString(dto.phoneNumber);
|
||||
}
|
||||
|
||||
const updated = await this.usersRepository.save(user);
|
||||
return this.toProfile(updated);
|
||||
}
|
||||
|
||||
async updatePassword(userId: string, dto: UpdatePasswordDto) {
|
||||
const user = await this.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(dto.currentPassword, user.passwordHash);
|
||||
|
||||
if (!matches) {
|
||||
throw new BadRequestException('Current password is incorrect');
|
||||
}
|
||||
|
||||
user.passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
const updated = await this.usersRepository.save(user);
|
||||
return this.toProfile(updated);
|
||||
}
|
||||
|
||||
toProfile(user: User): UserProfile {
|
||||
const { id, email, firstName, lastName, displayName, avatarUrl, jobTitle, location, bio, phoneNumber } = user;
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
firstName: firstName ?? null,
|
||||
lastName: lastName ?? null,
|
||||
displayName: displayName ?? null,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
jobTitle: jobTitle ?? null,
|
||||
location: location ?? null,
|
||||
bio: bio ?? null,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDisplayName(firstName?: string | null, lastName?: string | null, fallbackEmail?: string) {
|
||||
const parts = [firstName, lastName].filter((value): value is string => Boolean(value && value.trim()));
|
||||
|
||||
if (parts.length) {
|
||||
return parts.map((value) => value.trim()).join(' ');
|
||||
}
|
||||
|
||||
if (!fallbackEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fallbackEmail.includes('@') ? fallbackEmail.split('@')[0] : fallbackEmail;
|
||||
}
|
||||
|
||||
private normalizeString(value?: string | null) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
export function formatReleaseDate(date: Date | string | null | undefined): string | undefined {
|
||||
if (!date) return undefined;
|
||||
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (typeof date === 'string') {
|
||||
return date;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'jsx-a11y': jsxA11y,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
'react/prop-types': 'off',
|
||||
'no-undef': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
];
|
||||
@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Verto</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
const loadApp = () => import('/src/main.tsx');
|
||||
const SW_CLEANUP_FLAG = '__sw_cleanup_done__';
|
||||
|
||||
const cleanupServiceWorkers = async () => {
|
||||
if (!navigator.serviceWorker.getRegistrations) {
|
||||
loadApp();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
|
||||
if (registrations.length === 0) {
|
||||
sessionStorage.removeItem(SW_CLEANUP_FLAG);
|
||||
loadApp();
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()));
|
||||
|
||||
// Force a single reload so the page is no longer controlled by a stale SW.
|
||||
sessionStorage.setItem(SW_CLEANUP_FLAG, 'true');
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean up service workers', error);
|
||||
loadApp();
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionStorage.getItem(SW_CLEANUP_FLAG)) {
|
||||
sessionStorage.removeItem(SW_CLEANUP_FLAG);
|
||||
loadApp();
|
||||
} else if (import.meta.env.DEV && 'serviceWorker' in navigator) {
|
||||
cleanupServiceWorkers();
|
||||
} else {
|
||||
loadApp();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "client-release-manager",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"test": "vitest",
|
||||
"server": "npm --prefix ../backend run start:dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
||||
"@typescript-eslint/parser": "^8.7.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.8.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.4",
|
||||
"vitest": "^2.1.1"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.8.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1000 458.6" style="enable-background:new 0 0 1000 458.6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:none;}
|
||||
.st1{fill:#05699A;}
|
||||
.st2{fill:#68BBE1;}
|
||||
.st3{fill:#198BCC;}
|
||||
.st4{fill:#231F20;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st3" d="M186.4,150.6c0-3.7-1.3-7.1-3.8-9.5c-2.5-2.5-5.9-3.9-10.1-3.9c-3.3,0-6.1,0.7-8.2,2.3
|
||||
c-2.1,1.5-3.6,3.8-4.8,6.7l0,0L93.1,296.1L26.9,146.1l0,0c-1.2-2.9-2.6-5.1-4.8-6.7s-4.9-2.3-8.2-2.3c-4.2,0-7.6,1.4-10.1,3.9
|
||||
C1.3,143.6,0,147,0,150.6c0,2.6,1,5.3,1.9,7.2l0,0l78.9,176.2l0,0c1.2,2.4,2.5,4.4,4.5,5.9c2,1.5,4.6,2.4,8,2.4
|
||||
c3.3,0,5.9-0.8,8-2.4s3.3-3.7,4.5-5.9l0,0l78.9-176.2l0,0C185.5,156,186.4,153.3,186.4,150.6z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M382.1,237.6c0-28.4-9.6-53.5-26.3-71.4s-40.3-28.8-67.7-28.6c-29.5,0-54.4,12-72,30.8s-27.7,44.3-27.7,71.5
|
||||
c0,27,9.6,52.5,27.2,71.4c17.6,18.9,43.2,30.9,75.1,30.9c35.2,0,64.3-16.4,79.6-37.7l0,0l0,0c1.9-2.7,3.1-5.7,3.1-9.3
|
||||
c0-3.4-1.2-6.8-3.6-9.4c-2.4-2.5-5.8-4.2-10-4.2c-3,0-5.5,1-7.5,2.3c-2,1.3-3.4,3-4.6,4.3l0,0c-15.3,18.2-33.4,26.4-57.1,26.5
|
||||
c-22.2,0-39.3-8.2-51.3-20.6c-11.3-11.6-17.7-27.1-18.9-43h148.2C375.7,251,382,245.2,382.1,237.6z M288.1,164.5
|
||||
c18.1,0,33.3,6.8,44.6,17.9c10.7,10.6,17.7,25.1,19.8,41.6h-131C226.1,190.9,252.2,164.5,288.1,164.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M511.1,141.3c-3-2.5-6.9-3.7-11.2-3.7c-27.6,0-49.7,14.3-61.1,34.6v-18.7c0-4.4-1.5-8.4-4.4-11.3
|
||||
c-2.9-2.9-6.9-4.6-11.5-4.6c-4.4,0-8.4,1.5-11.3,4.4s-4.6,6.9-4.6,11.5v172.6c0.1,8.7,7.2,15.8,15.9,15.9c9,0,15.9-7.4,15.9-15.9
|
||||
v-76.6c0-24.4,6.3-45.3,17.1-60c10.8-14.7,25.9-23.3,44-23.3c4,0,8-1.1,10.9-3.6c3.1-2.5,5-6.3,5-11
|
||||
C515.9,147.4,514.1,143.6,511.1,141.3z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M662,137.7h-42.1V78.4c0-4.4-1.7-8.4-4.6-11.3c-2.9-2.9-6.9-4.6-11.3-4.6c-4.4,0-8.4,1.7-11.3,4.6
|
||||
c-2.9,2.9-4.6,6.9-4.6,11.3v59.3h-36.7c-3.8,0-7.2,1.5-9.7,3.9c-2.5,2.5-3.9,5.9-3.9,9.7s1.5,7.2,3.9,9.7c2.5,2.5,5.9,3.9,9.7,3.9
|
||||
h36.7v160.3c0,4.4,1.7,8.6,4.5,11.5s6.9,5,11.4,5s8.6-1.8,11.3-4.8c2.9-3,4.5-7,4.5-11.4V165.1h42.1c7.2,0,13.7-5.9,13.7-13.7
|
||||
C675.5,143.6,669.1,137.8,662,137.7z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M841.5,429.1c3.7,8.6,12.2,14.5,22.1,14.5c13.3,0,24-10.8,24-24c0-4-1-7.8-2.7-11.2c-4.2-7.4-12-12.4-21-12.4
|
||||
c-13.3,0-24.1,10.8-24.1,24.1C839.8,423.3,840.4,426.4,841.5,429.1z"/>
|
||||
<path class="st0" d="M724.2,59.3c-13.3,0-24.1,10.8-24.1,24.1c0,2.5,0.4,4.9,1.1,7.1c3.1,9.7,12.1,16.8,22.9,16.8
|
||||
c13.3,0,24-10.8,24-24c0-6.7-2.7-12.6-7-17C736.8,62,730.9,59.3,724.2,59.3z"/>
|
||||
<circle class="st0" cx="772.5" cy="239.2" r="74.8"/>
|
||||
<path class="st0" d="M961.7,285.6c-13.3,0-24.1,10.8-24.1,24.1c0,5.7,2,11,5.3,15.1c4.4,5.2,10.9,8.6,18.3,8.6
|
||||
c13.3,0,24-10.8,24-24c0-6.7-2.6-12.6-7-16.9C974.1,288.2,968.2,285.6,961.7,285.6z"/>
|
||||
<path class="st0" d="M897.8,98.3c4.6,1.9,9.6,3,14.9,3c21.3,0,38.5-17.2,38.5-38.5c0-19.8-15.1-36.2-34.3-38.3
|
||||
c-1.2-0.1-2.5-0.2-3.8-0.2c-21.3,0-38.5,17.2-38.5,38.5C874.5,78.8,884.2,92.3,897.8,98.3z"/>
|
||||
<path class="st1" d="M850.2,62.4l-16.8,1.8c-0.1,0-0.2,0-0.4,0l-54.8,5.9c-0.1,0-0.2,0-0.4,0L761,72c-2.6-8.6-8.1-15.8-15.3-20.7
|
||||
c-6.2-4.2-13.5-6.7-21.5-6.7c-21.3,0-38.5,17.2-38.5,38.5c0,2.6,0.2,5.2,0.8,7.7c3.6,17.6,19.1,30.9,37.8,30.9
|
||||
c20.3,0,36.8-15.7,38.4-35.5l89.1-9.7c-1.1-4.5-1.7-9.3-1.7-14.1C850.2,62.5,850.2,62.4,850.2,62.4z M724.2,107.4
|
||||
c-10.8,0-19.8-7-22.9-16.8c-0.7-2.3-1.1-4.6-1.1-7.1c0-13.3,10.8-24.1,24.1-24.1c6.7,0,12.6,2.6,17,7c4.4,4.4,7,10.3,7,17
|
||||
C748.2,96.6,737.5,107.4,724.2,107.4z"/>
|
||||
<path class="st2" d="M913,0h-0.1h-0.1c-34.5,0-62.4,27.9-62.5,62.4v0.1c0,4.9,0.6,9.6,1.7,14.1c2.7,11.5,8.6,21.9,16.6,30.1
|
||||
L858,120c-0.1,0.1-0.1,0.1-0.2,0.2l-17.6,22c0,0.1-0.1,0.1-0.1,0.2l-10.2,12.8c4,2.7,7.7,5.7,11.3,9l38.6-48.6
|
||||
c1.1,0.6,2.1,1.3,3.2,1.9c9.9,5.5,21.4,8.2,33.6,7.6c31.1-1.8,56.6-26.6,58.8-57.7C978.1,30.7,949.1,0,913,0z M916.6,101
|
||||
c-6.7,0.7-13.1-0.4-18.8-2.7c-13.8-5.9-23.3-19.5-23.3-35.4c0-21.3,17.2-38.5,38.5-38.5c1.3,0,2.5,0.1,3.8,0.2
|
||||
c20.4,2.1,36.1,20.3,34.2,41.8C949.4,84.5,934.8,99.2,916.6,101z"/>
|
||||
<path class="st1" d="M987.3,281.6c-6.9-6.3-15.9-10.1-26-10.1c-13.8,0-25.9,7.2-32.7,18.2L869.9,268c-1.4,4.6-3.1,9.1-5.1,13.4
|
||||
l15.4,5.7c0.1,0.1,0.4,0.1,0.5,0.2L907,297c0.1,0,0.4,0.1,0.5,0.1l15.8,5.8c-0.4,2.3-0.6,4.5-0.6,6.9c0,8.8,3,17,8,23.4
|
||||
c7,9.4,18.3,15.4,30.9,15.4c21.3,0,38.5-17.2,38.5-38.5C1000.2,298.9,995.2,288.6,987.3,281.6z M961.4,333.4
|
||||
c-7.4,0-13.9-3.3-18.3-8.6c-3.3-4.2-5.3-9.4-5.3-15.1c0-13.3,10.8-24.1,24.1-24.1c6.5,0,12.4,2.6,16.8,6.8c4.3,4.4,7,10.3,7,16.9
|
||||
C985.4,322.6,974.6,333.4,961.4,333.4z"/>
|
||||
<path class="st2" d="M840.1,389.2c-9,7-14.9,18.1-14.9,30.4c0,4.8,0.8,9.3,2.4,13.4c5.3,14.9,19.6,25.5,36.2,25.5
|
||||
c21.3,0,38.5-17.2,38.5-38.5c0-6.9-1.8-13.2-4.9-18.8c-6.5-12-19.2-20.2-33.9-20.2c-3.7,0-7.4,0.6-10.8,1.5l-7.8-15.3
|
||||
c0-0.1-0.1-0.1-0.1-0.2l-12.6-24.9c0-0.1-0.1-0.1-0.1-0.2l-7.8-15.3c-4.2,2.5-8.4,4.6-12.8,6.4L840.1,389.2z M863.8,396.1
|
||||
c9,0,17,5,21,12.4c1.8,3.3,2.7,7.1,2.7,11.2c0,13.3-10.8,24-24,24c-9.9,0-18.4-5.9-22.1-14.5c-1.1-2.7-1.8-5.8-1.8-9
|
||||
C839.8,406.8,850.5,396.1,863.8,396.1z"/>
|
||||
<g>
|
||||
<path class="st3" d="M841.1,164.3c-3.6-3.2-7.4-6.3-11.3-9c-16.4-11.2-36.1-17.7-57.4-17.7c-56.2,0-101.7,45.5-101.7,101.7
|
||||
S716.3,341,772.5,341c13.8,0,27-2.7,39-7.7c4.5-1.9,8.8-4,12.8-6.4c17.7-10.6,32-26.3,40.6-45.1c2-4.4,3.7-8.8,5.1-13.4
|
||||
c2.7-9.1,4.2-18.8,4.2-28.9C874.2,209.6,861.5,182.8,841.1,164.3z M772.5,314c-41.2,0-74.8-33.6-74.8-74.8s33.6-74.8,74.8-74.8
|
||||
s74.8,33.6,74.8,74.8C847.3,280.5,813.7,314,772.5,314z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
@ -0,0 +1,25 @@
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ReleasesProvider } from './contexts/ReleasesContext';
|
||||
import { AppContent } from './components/AppContent';
|
||||
import { TransactionsProvider } from './contexts/TransactionsContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { OrganizationsProvider } from './contexts/OrganizationsContext';
|
||||
import { CodesProvider } from './contexts/CodesContext';
|
||||
|
||||
const App = () => (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<OrganizationsProvider>
|
||||
<ReleasesProvider>
|
||||
<TransactionsProvider>
|
||||
<CodesProvider>
|
||||
<AppContent />
|
||||
</CodesProvider>
|
||||
</TransactionsProvider>
|
||||
</ReleasesProvider>
|
||||
</OrganizationsProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@ -0,0 +1,26 @@
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { AuthScreen } from './auth/AuthScreen';
|
||||
import { Dashboard } from './dashboard/Dashboard';
|
||||
import { AcceptInviteScreen } from './auth/AcceptInviteScreen';
|
||||
import { useInviteToken } from '../hooks/useInviteToken';
|
||||
import { Footer } from './common/Footer';
|
||||
|
||||
export const AppContent = () => {
|
||||
const { currentUser } = useAuth();
|
||||
const { inviteToken, clearInviteToken } = useInviteToken();
|
||||
|
||||
let screen = <Dashboard />;
|
||||
|
||||
if (inviteToken) {
|
||||
screen = <AcceptInviteScreen token={inviteToken} onComplete={clearInviteToken} />;
|
||||
} else if (!currentUser) {
|
||||
screen = <AuthScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<main className="app-shell__main">{screen}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,131 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import styles from './AuthScreen.module.css';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import type { InviteDetails } from '../../services/api';
|
||||
|
||||
interface AcceptInviteScreenProps {
|
||||
token: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export const AcceptInviteScreen = ({ token, onComplete }: AcceptInviteScreenProps) => {
|
||||
const { loadInvite, acceptInvite } = useAuth();
|
||||
const [invite, setInvite] = useState<InviteDetails | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [error, setError] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setStatus('loading');
|
||||
setError('');
|
||||
|
||||
loadInvite(token)
|
||||
.then((details) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInvite(details);
|
||||
setStatus('ready');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Invite not found.');
|
||||
setStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [loadInvite, token]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!invite) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
await acceptInvite(token, password || undefined);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Unable to accept invite. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper}>
|
||||
<div className={styles.panel}>
|
||||
<h1 className={styles.title}>Accept invite</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Join an existing project to edit release information alongside your teammates.
|
||||
</p>
|
||||
|
||||
{status === 'loading' && <div className={styles.subtitle}>Checking your invite…</div>}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className={styles.error}>{error}</div>
|
||||
<button type="button" className="btn btn--filled" onClick={onComplete}>
|
||||
Back to sign in
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'ready' && invite && (
|
||||
<>
|
||||
<div className={styles.meta}>
|
||||
<p>
|
||||
<strong>{invite.inviterEmail || 'A teammate'}</strong> invited you to collaborate on{' '}
|
||||
<strong>{invite.projectName}</strong>.
|
||||
</p>
|
||||
<p>This invite was sent to {invite.email}.</p>
|
||||
</div>
|
||||
|
||||
{submitError && <div className={styles.error}>{submitError}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label} htmlFor="invite-password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="invite-password"
|
||||
type="password"
|
||||
placeholder="Set a password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className={styles.helper}>
|
||||
Set a password if you are new here. If you already have an account, you can leave this blank.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={`btn btn--filled ${styles.submitButton}`} disabled={isSubmitting}>
|
||||
Join project
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.toggle}>
|
||||
<button type="button" onClick={onComplete}>
|
||||
Use a different email
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,95 @@
|
||||
.wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(400px, 100%);
|
||||
background: #fff;
|
||||
border: 1px solid var(--verto-panel-border);
|
||||
padding: 2.5rem;
|
||||
}
|
||||
.logo {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
display: block;
|
||||
margin: 0 auto 12px auto;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--verto-muted);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f5f5f5;
|
||||
color: #000;
|
||||
border: 1px solid var(--verto-panel-border);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--verto-muted);
|
||||
text-decoration: underline;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid var(--verto-panel-border);
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.meta p {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.meta p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--verto-muted);
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import styles from './AuthScreen.module.css';
|
||||
|
||||
export const AuthScreen = () => {
|
||||
const { login, signup } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!email || !password) {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'signup') {
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setError('Please provide your full name.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(email, password);
|
||||
} else {
|
||||
await signup({
|
||||
email,
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to authenticate. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode((current) => (current === 'login' ? 'signup' : 'login'));
|
||||
setError('');
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper}>
|
||||
<div className={styles.panel}>
|
||||
<img src="/verto.svg" alt="Verto Logo" className={styles.logo} />
|
||||
<p className={styles.subtitle}>
|
||||
{mode === 'login' ? 'Sign in to your release cockpit' : 'Create your Verto workspace'}
|
||||
</p>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
{mode === 'signup' && (
|
||||
<>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label} htmlFor="firstName">
|
||||
First name
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Alex"
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label} htmlFor="lastName">
|
||||
Last name
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(event) => {
|
||||
setLastName(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Chen"
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label} htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="you@company.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label} htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Enter your password"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={`btn btn--filled ${styles.submitButton}`} disabled={isSubmitting}>
|
||||
{mode === 'login' ? 'Sign In' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.toggle}>
|
||||
<button type="button" onClick={toggleMode}>
|
||||
{mode === 'login' ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
.footer {
|
||||
border-top: 1px solid var(--verto-border);
|
||||
background: #fff;
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 0;
|
||||
color: var(--verto-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.copy {
|
||||
margin: 0;
|
||||
color: var(--verto-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.inner}>
|
||||
<div>
|
||||
<p className={styles.logo}>Verto</p>
|
||||
<p className={styles.tagline}>Release intelligence for product and platform teams.</p>
|
||||
</div>
|
||||
<p className={styles.copy}>© {new Date().getFullYear()} Verto Labs. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
width: min(640px, 100%);
|
||||
max-height: 90vh;
|
||||
border: 1px solid var(--verto-panel-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--verto-panel-border);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styles from './Modal.module.css';
|
||||
import { CloseIcon } from './icons';
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Modal = ({ title, isOpen, onClose, children }: ModalProps) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.backdrop} role="dialog" aria-modal="true">
|
||||
<div className={styles.panel}>
|
||||
<header className={styles.header}>
|
||||
<h2>{title}</h2>
|
||||
<button className={styles.closeButton} onClick={onClose} aria-label="Close modal">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,87 @@
|
||||
export const PlusIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EditIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DeleteIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DownloadIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CloseIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LogoutIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ShareIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SettingsIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l0 0a2 2 0 1 1-2.83 2.83l0 0A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-3 0 1.65 1.65 0 0 0-.33 1.82l0 0a2 2 0 1 1-2.83-2.83l0 0A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.82-.33l0 0a2 2 0 1 1-2.83-2.83l0 0A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0 .33-1.82l0 0a2 2 0 1 1 2.83-2.83l0 0A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 3 0 1.65 1.65 0 0 0 .33-1.82l0 0a2 2 0 1 1 2.83 2.83l0 0A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.82.33l0 0a2 2 0 1 1 2.83 2.83l0 0A1.65 1.65 0 0 0 19.4 15z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ListViewIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CardViewIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1,42 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--verto-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.helperText {
|
||||
margin: -0.35rem 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--verto-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--verto-black);
|
||||
background: var(--verto-highlight);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import styles from './AddOrganizationForm.module.css';
|
||||
|
||||
interface AddOrganizationFormProps {
|
||||
onSubmit: (values: { name: string; code: string }) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const AddOrganizationForm = ({ onSubmit, onCancel }: AddOrganizationFormProps) => {
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!name.trim() || !code.trim()) {
|
||||
setError('Provide both an organization name and code.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSubmit({ name: name.trim(), code: code.trim() });
|
||||
setName('');
|
||||
setCode('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to add organization.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<p className={styles.description}>Create an organization once and reuse it across releases and transaction events.</p>
|
||||
|
||||
<label className={styles.label} htmlFor="organization-name">
|
||||
Organization name
|
||||
</label>
|
||||
<input
|
||||
id="organization-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Acme Corporation"
|
||||
/>
|
||||
|
||||
<label className={styles.label} htmlFor="organization-code">
|
||||
Organization code
|
||||
</label>
|
||||
<input
|
||||
id="organization-code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(event) => {
|
||||
setCode(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="acme"
|
||||
/>
|
||||
<p className={styles.helperText}>Codes should be short, unique identifiers (no spaces). We'll use them in URLs.</p>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" className="btn btn--filled" disabled={isSubmitting}>
|
||||
Save organization
|
||||
</button>
|
||||
<button type="button" className="btn btn--ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue