Compare commits

..

1 Commits

Author SHA1 Message Date
Naeem Ullah 70b3c77fa6 Initial project setup with backend and frontend
Add full-stack release tracker with NestJS backend and React/Vite frontend. Includes environment configs, Docker setup, database migration scripts, core backend modules (auth, projects, releases, transaction codes, users), frontend dashboard components, context providers, and utility files.
46 minutes ago

@ -0,0 +1,4 @@
VITE_SUPABASE_URL=https://krkgvhstckknyylgqvyk.supabase.co
VITE_SUPABASE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtya2d2aHN0Y2trbnl5bGdxdnlrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYzODI5MTIsImV4cCI6MjA4MTk1ODkxMn0.T05AMrHbcfHZIR7wMcIOaWGDuYGjFEOgRaZ_lz4DPPg
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtya2d2aHN0Y2trbnl5bGdxdnlrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYzODI5MTIsImV4cCI6MjA4MTk1ODkxMn0.T05AMrHbcfHZIR7wMcIOaWGDuYGjFEOgRaZ_lz4DPPg

13
.gitignore vendored

@ -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,76 @@
# Verto
Verto is a full-stack release tracker built with React + Vite on the frontend and NestJS + MySQL on the backend. Users can sign up, authenticate with JWTs, manage client/environment release metadata, and customize their personal settings (profile, avatar, password) without leaving the workspace.
## Repo layout
- `backend/` NestJS + TypeORM API (JWT auth, releases CRUD, MySQL integration)
- `frontend/` React + Vite dashboard (shown below)
## Getting started
1. Install dependencies
```bash
(cd backend && npm install)
(cd frontend && npm install)
```
2. Configure environment variables
The backend now looks for three `.env*` files:
- `backend/.env` production defaults (gitignored). Copy `backend/.env.example` and fill in real secrets.
- `backend/.env-development` used whenever `NODE_ENV` is unset or `development`. Update this file for local dev.
- `backend/.env-test` used when `NODE_ENV=test`, e.g., during Jest/e2e runs.
```bash
cp backend/.env.example backend/.env # production / deployment secrets
```
Ensure a MySQL database (default `verto`) exists and the configured user has permissions. Create separate schemas (`verto_dev`, `verto_test`) if you keep the dev/test defaults. `NODE_ENV` controls which file loads (defaults to `development`); Jest sets `NODE_ENV=test` automatically, and `NODE_ENV=production npm run start:prod` will read from `.env`.
3. Run the dev servers (in separate terminals)
```bash
(cd backend && npm run start:dev) # http://localhost:3000
(cd frontend && npm run dev) # http://localhost:5173
```
4. Optionally build/preview the frontend
```bash
(cd frontend && npm run build)
(cd frontend && npm run preview)
```
## Project structure
```
frontend/src/
├─ components/
│ ├─ AppContent.tsx
│ ├─ auth/
│ ├─ common/
│ └─ dashboard/
├─ contexts/
├─ services/
├─ styles/
├─ types/
└─ utils/
```
- `contexts/` keeps isolated auth + release providers.
- `services/` holds API + session helpers.
- `components/` are split by feature (auth vs dashboard) with smaller presentational children.
- `utils/` centralizes data shaping helpers (flattening/grouping releases, exporting JSON, etc.).
- `styles/` includes shared tokens plus CSS modules per component for maintainable styling.
## Features
- Email + password auth persisted in MySQL and secured with JWTs
- Add/edit/delete release metadata per client/environment with server-side validation
- Invite collaborators to specific client projects via emailed signup links
- Search across clients, branches, versions, and environments
- JSON export of the current user's release catalog
- Responsive layout with accessible modals and form semantics

@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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,205 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import nodemailer, { Transporter } from 'nodemailer';
interface ProjectInviteTemplate {
inviteLink: string;
projectName: string;
inviterEmail: string;
}
interface ReleaseUpdateTemplate {
projectName: string;
projectSlug: string;
environment: string;
version: string;
branch: string;
build: number;
date: string;
commitMessage: string | null;
actorName: string;
actorEmail: string;
isNewRelease: boolean;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly transporter: Transporter | null;
constructor(private readonly configService: ConfigService) {
this.transporter = this.createTransporter();
}
async sendProjectInvite(recipient: string, template: ProjectInviteTemplate) {
if (!this.transporter) {
this.logger.warn(
`Email transport not configured. Invite for ${recipient}: ${template.inviteLink}`,
);
return;
}
try {
await this.transporter.sendMail({
from: this.configService.get<string>('EMAIL_FROM'),
to: recipient,
subject: `Invitation to collaborate on ${template.projectName}`,
text: this.buildPlainText(template),
html: this.buildHtml(template),
});
} catch (error) {
this.logger.error(
'Failed to send invite email',
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
async sendReleaseUpdateNotification(recipient: string, template: ReleaseUpdateTemplate) {
if (!this.transporter) {
this.logger.warn(
`Email transport not configured. Release update for ${recipient} in ${template.projectSlug}`,
);
return;
}
try {
await this.transporter.sendMail({
from: this.configService.get<string>('EMAIL_FROM'),
to: recipient,
subject: this.buildReleaseSubject(template),
text: this.buildReleasePlainText(template),
html: this.buildReleaseHtml(template),
});
} catch (error) {
this.logger.error(
'Failed to send release update email',
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
private createTransporter(): Transporter | null {
const host = this.configService.get<string>('SMTP_HOST');
const port = Number(this.configService.get<string>('SMTP_PORT'));
if (!host || !port) {
this.logger.warn('SMTP configuration missing. Emails will not be sent.');
return null;
}
const secure =
this.configService.get<string>('SMTP_SECURE') === 'true' || port === 465;
const auth = {
user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASSWORD'),
};
return nodemailer.createTransport({
host,
port,
secure,
auth,
});
}
private buildPlainText({ projectName, inviteLink, inviterEmail }: ProjectInviteTemplate) {
return `
${inviterEmail} has invited you to collaborate on the project "${projectName}".
To accept the invitation, open this link:
${inviteLink}
If you did not expect this email, you can safely ignore it.
`;
}
private buildHtml({ projectName, inviteLink, inviterEmail }: ProjectInviteTemplate) {
return `
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
<h2 style="font-weight: 600; font-size: 20px;">You've been invited!</h2>
<p style="font-size: 15px;">
<strong>${inviterEmail}</strong> has invited you to collaborate on the project
<strong>${projectName}</strong>.
</p>
<a href="${inviteLink}"
style="display: inline-block; margin: 20px 0; padding: 12px 22px; background-color: #4f46e5;
color: #fff; text-decoration: none; border-radius: 6px; font-size: 15px;">
Accept Invitation
</a>
<p style="font-size: 14px; color: #666; margin-top: 20px;">
If the button above doesnt work, copy and paste this link into your browser:
</p>
<p style="font-size: 14px; color: #555;">
${inviteLink}
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;" />
<p style="font-size: 12px; color: #999;">
This email was sent automatically. If you did not expect it, you can ignore it.
</p>
</div>
`;
}
private buildReleaseSubject(template: ReleaseUpdateTemplate) {
const action = template.isNewRelease ? 'created' : 'updated';
return `${template.projectName}: ${template.environment} release ${action}`;
}
private buildReleasePlainText(template: ReleaseUpdateTemplate) {
const action = template.isNewRelease ? 'created' : 'updated';
const lines = [
`${template.actorName} (${template.actorEmail}) ${action} a release for ${template.projectName}.`,
'',
`Environment: ${template.environment}`,
`Version: ${template.version}`,
`Branch: ${template.branch}`,
`Build: ${template.build}`,
`Date: ${template.date}`,
];
if (template.commitMessage) {
lines.push(`Commit message: ${template.commitMessage}`);
}
lines.push('', 'You are receiving this because you collaborate on this project.');
return lines.join('\n');
}
private buildReleaseHtml(template: ReleaseUpdateTemplate) {
const action = template.isNewRelease ? 'created' : 'updated';
const commitMessageSection = template.commitMessage
? `<p style="font-size: 14px; color: #444;"><strong>Commit message:</strong> ${template.commitMessage}</p>`
: '';
return `
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
<h2 style="font-weight: 600; font-size: 20px; margin-bottom: 10px;">
${template.projectName} &mdash; ${template.environment} release ${action}
</h2>
<p style="font-size: 15px; color: #444;">
${template.actorName} (${template.actorEmail}) ${action} a release for this project.
</p>
<div style="font-size: 14px; line-height: 1.6; color: #333; background: #f5f5ff; padding: 16px; border-radius: 8px;">
<p style="margin: 0;"><strong>Version:</strong> ${template.version}</p>
<p style="margin: 0;"><strong>Branch:</strong> ${template.branch}</p>
<p style="margin: 0;"><strong>Build:</strong> ${template.build}</p>
<p style="margin: 0;"><strong>Date:</strong> ${template.date}</p>
</div>
${commitMessageSection}
<p style="font-size: 12px; color: #777; margin-top: 20px;">
You are receiving this because you collaborate on ${template.projectName}.
</p>
</div>
`;
}
}

@ -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}>&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&apos;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…
Cancel
Save