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