Initial project setup with backend and frontend
Add full-stack release tracker with NestJS backend and React/Vite frontend. Includes environment configs, Docker setup, database migration scripts, core backend modules (auth, projects, releases, transaction codes, users), frontend dashboard components, context providers, and utility files.Master
parent
f0388217ab
commit
70b3c77fa6
@ -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