mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-15 20:30:33 +00:00
Compare commits
65 Commits
auth-pages
...
feature/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
889b0cec4b | ||
|
|
17651e0768 | ||
|
|
151b623771 | ||
|
|
2d4459c2f9 | ||
|
|
3cbdc3ec96 | ||
|
|
3cfb5cdde8 | ||
|
|
736f2c4109 | ||
|
|
2e21437056 | ||
|
|
340b78d968 | ||
|
|
d006362be2 | ||
|
|
bc21dcb37e | ||
|
|
578b0deb3e | ||
|
|
c3dc26a1e4 | ||
|
|
32d74b0413 | ||
|
|
71b1206f8a | ||
|
|
cb1bcaae77 | ||
|
|
eb51646035 | ||
|
|
8f54754aba | ||
|
|
0f446f90ca | ||
|
|
7cb67b257b | ||
|
|
0a1fffb3a4 | ||
|
|
b756f090ed | ||
|
|
f9e49727fc | ||
|
|
66969753b1 | ||
|
|
3648fb3ffc | ||
|
|
e196d485cf | ||
|
|
74e46364ac | ||
|
|
8817be4813 | ||
|
|
cd4816aa3b | ||
|
|
82a2c74182 | ||
|
|
e231efb9de | ||
|
|
65ffc31ec0 | ||
|
|
dc6cf13a3e | ||
|
|
adfa8852db | ||
|
|
ff04c4b762 | ||
|
|
fe4bd88f9f | ||
|
|
caf232d928 | ||
|
|
234b1804b3 | ||
|
|
98b3b551c1 | ||
|
|
ceed9e453f | ||
|
|
43faa45dcf | ||
|
|
56e00d254b | ||
|
|
d90b6ffbe7 | ||
|
|
5eafd23bf8 | ||
|
|
2b384b2f6f | ||
|
|
17bcc14231 | ||
|
|
2c64e1b8ab | ||
|
|
6f50138260 | ||
|
|
0a7d687f91 | ||
|
|
2383091b6e | ||
|
|
e2f5d4c66e | ||
|
|
ce70234ebd | ||
|
|
80abd1f66f | ||
|
|
a0bc9db9a6 | ||
|
|
8161439365 | ||
|
|
46871c8113 | ||
|
|
a4aee58f93 | ||
|
|
f64875404a | ||
|
|
cca116f6bb | ||
|
|
fdec94a3f7 | ||
|
|
c66299aacd | ||
|
|
77dc0778a3 | ||
|
|
a76445a6eb | ||
|
|
41143d8bbd | ||
|
|
844a050c9a |
@@ -168,6 +168,16 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"bug"
|
"bug"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Daniel15",
|
||||||
|
"name": "Daniel Lo Nigro",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/91933?v=4",
|
||||||
|
"profile": "https://d.sb/",
|
||||||
|
"contributions": [
|
||||||
|
"bug",
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
|||||||
12
.env.example
12
.env.example
@@ -98,8 +98,18 @@ POSTHOG_API_KEY=
|
|||||||
POSTHOG_HOST=
|
POSTHOG_HOST=
|
||||||
|
|
||||||
# Stripe Payment
|
# Stripe Payment
|
||||||
|
# Get the keys from the Stripe dashboard
|
||||||
|
# Starts with "sk_"
|
||||||
STRIPE_PAYMENT_SECRET_KEY=
|
STRIPE_PAYMENT_SECRET_KEY=
|
||||||
|
# Starts with "pk_"
|
||||||
STRIPE_PAYMENT_PUBLISHABLE_KEY=
|
STRIPE_PAYMENT_PUBLISHABLE_KEY=
|
||||||
|
# Get the client ID from https://dashboard.stripe.com/settings/connect/onboarding-options/oauth
|
||||||
|
# Starts with "ca_"
|
||||||
STRIPE_PAYMENT_CLIENT_ID=
|
STRIPE_PAYMENT_CLIENT_ID=
|
||||||
|
# Configure the webhook here: https://dashboard.stripe.com/workbench/webhooks/
|
||||||
|
# Endpoint URL is https://example.com/api/webhooks/stripe (replace "example.com" with the correct domain)
|
||||||
|
# Select the "checkout.session.completed" and "account.updated" events
|
||||||
|
# Starts with "whsec_"
|
||||||
STRIPE_PAYMENT_WEBHOOKS_SECRET=
|
STRIPE_PAYMENT_WEBHOOKS_SECRET=
|
||||||
STRIPE_PAYMENT_REDIRECT_URL=
|
# Replace example.com with the correct domain
|
||||||
|
STRIPE_PAYMENT_REDIRECT_URL=https://example.com/preferences/payment-methods/stripe/callback
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pnpm install
|
|||||||
- Run all required docker containers in the development, we already configured all containers under `docker-compose.yml`.
|
- Run all required docker containers in the development, we already configured all containers under `docker-compose.yml`.
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait some seconds, and hit `docker-compose ps` and you should see the same result below.
|
Wait some seconds, and hit `docker-compose ps` and you should see the same result below.
|
||||||
@@ -75,7 +75,7 @@ pnpm run build:server
|
|||||||
- Run the database migration for system database.
|
- Run the database migration for system database.
|
||||||
|
|
||||||
```
|
```
|
||||||
node packages/server/build/commands.js system:migrate:latest
|
pnpm run system:migrate:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
And you should get something like that.
|
And you should get something like that.
|
||||||
@@ -84,10 +84,10 @@ And you should get something like that.
|
|||||||
Batch 1 run: 6 migrations
|
Batch 1 run: 6 migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
- Next, start the webapp application.
|
- Next, start the server.
|
||||||
|
|
||||||
```
|
```
|
||||||
pnpm run dev:server
|
pnpm run server:start
|
||||||
```
|
```
|
||||||
|
|
||||||
**[`^top^`](#)**
|
**[`^top^`](#)**
|
||||||
@@ -96,12 +96,6 @@ pnpm run dev:server
|
|||||||
|
|
||||||
## Contribute to Frontend
|
## Contribute to Frontend
|
||||||
|
|
||||||
- Clone the `bigcapital` repository and cd into `bigcapital` directory.
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone https://github.com/bigcapital/bigcapital.git && cd bigcaptial
|
|
||||||
```
|
|
||||||
|
|
||||||
- Install all npm dependencies of the monorepo, you don't have to change directory to the `frontend` package. just hit that command and will install all packages across all application.
|
- Install all npm dependencies of the monorepo, you don't have to change directory to the `frontend` package. just hit that command and will install all packages across all application.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -138,4 +132,4 @@ There are many other ways to get involved with the community and to participate
|
|||||||
|
|
||||||
Again, Feel free to ping us on [`#contributing`](https://discord.com/invite/c8nPBJafeb) on our Discord community if you need any help on this :)
|
Again, Feel free to ping us on [`#contributing`](https://discord.com/invite/c8nPBJafeb) on our Discord community if you need any help on this :)
|
||||||
|
|
||||||
Thank You!
|
Thank You!
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://myself.vercel.app/"><img src="https://avatars.githubusercontent.com/u/42431274?v=4?s=100" width="100px;" alt="Sachin Mittal"/><br /><sub><b>Sachin Mittal</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Amittalsam98" title="Bug reports">🐛</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://myself.vercel.app/"><img src="https://avatars.githubusercontent.com/u/42431274?v=4?s=100" width="100px;" alt="Sachin Mittal"/><br /><sub><b>Sachin Mittal</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Amittalsam98" title="Bug reports">🐛</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.camilooviedo.com/"><img src="https://avatars.githubusercontent.com/u/64604272?v=4?s=100" width="100px;" alt="Camilo Oviedo"/><br /><sub><b>Camilo Oviedo</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=Champetaman" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.camilooviedo.com/"><img src="https://avatars.githubusercontent.com/u/64604272?v=4?s=100" width="100px;" alt="Camilo Oviedo"/><br /><sub><b>Camilo Oviedo</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=Champetaman" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://nklmantey.com/"><img src="https://avatars.githubusercontent.com/u/90279429?v=4?s=100" width="100px;" alt="Mantey"/><br /><sub><b>Mantey</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Anklmantey" title="Bug reports">🐛</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nklmantey.com/"><img src="https://avatars.githubusercontent.com/u/90279429?v=4?s=100" width="100px;" alt="Mantey"/><br /><sub><b>Mantey</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Anklmantey" title="Bug reports">🐛</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://d.sb/"><img src="https://avatars.githubusercontent.com/u/91933?v=4?s=100" width="100px;" alt="Daniel Lo Nigro"/><br /><sub><b>Daniel Lo Nigro</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3ADaniel15" title="Bug reports">🐛</a> <a href="https://github.com/bigcapitalhq/bigcapital/commits?author=Daniel15" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ services:
|
|||||||
- OPEN_EXCHANGE_RATE_APP_ID-${OPEN_EXCHANGE_RATE_APP_ID}
|
- OPEN_EXCHANGE_RATE_APP_ID-${OPEN_EXCHANGE_RATE_APP_ID}
|
||||||
|
|
||||||
# Bank Sync
|
# Bank Sync
|
||||||
- BANKING_CONNECT=${BANKING_CONNECT}
|
- BANK_FEED_ENABLED=${BANK_FEED_ENABLED}
|
||||||
|
|
||||||
# Plaid
|
# Plaid
|
||||||
- PLAID_ENV=${PLAID_ENV}
|
- PLAID_ENV=${PLAID_ENV}
|
||||||
@@ -122,6 +122,13 @@ services:
|
|||||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||||
- S3_BUCKET=${S3_BUCKET}
|
- S3_BUCKET=${S3_BUCKET}
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
- STRIPE_PAYMENT_SECRET_KEY=${STRIPE_PAYMENT_SECRET_KEY}
|
||||||
|
- STRIPE_PAYMENT_PUBLISHABLE_KEY=${STRIPE_PAYMENT_PUBLISHABLE_KEY}
|
||||||
|
- STRIPE_PAYMENT_CLIENT_ID=${STRIPE_PAYMENT_CLIENT_ID}
|
||||||
|
- STRIPE_PAYMENT_WEBHOOKS_SECRET=${STRIPE_PAYMENT_WEBHOOKS_SECRET}
|
||||||
|
- STRIPE_PAYMENT_REDIRECT_URL=${STRIPE_PAYMENT_REDIRECT_URL}
|
||||||
|
|
||||||
database_migration:
|
database_migration:
|
||||||
container_name: bigcapital-database-migration
|
container_name: bigcapital-database-migration
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM redis:6.2.0
|
FROM redis:6.2.21
|
||||||
|
|
||||||
COPY redis.conf /usr/local/etc/redis/redis.conf
|
COPY redis.conf /usr/local/etc/redis/redis.conf
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@casl/ability": "^5.4.3",
|
"@casl/ability": "^5.4.3",
|
||||||
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
||||||
"@liaoliaots/nestjs-redis": "^10.0.0",
|
"@liaoliaots/nestjs-redis": "^10.0.0",
|
||||||
|
"@nest-lab/throttler-storage-redis": "^1.1.0",
|
||||||
"@nestjs/bull": "^10.2.1",
|
"@nestjs/bull": "^10.2.1",
|
||||||
"@nestjs/bullmq": "^10.2.2",
|
"@nestjs/bullmq": "^10.2.2",
|
||||||
"@nestjs/cache-manager": "^2.2.2",
|
"@nestjs/cache-manager": "^2.2.2",
|
||||||
|
|||||||
5
packages/server/src/common/config/app.ts
Normal file
5
packages/server/src/common/config/app.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('app', () => ({
|
||||||
|
baseUrl: process.env.BASE_URL,
|
||||||
|
}));
|
||||||
5
packages/server/src/common/config/cloud.ts
Normal file
5
packages/server/src/common/config/cloud.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('cloud', () => ({
|
||||||
|
hostedOnCloud: process.env.HOSTED_ON_BIGCAPITAL_CLOUD === 'true',
|
||||||
|
}));
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import app from './app';
|
||||||
import systemDatabase from './system-database';
|
import systemDatabase from './system-database';
|
||||||
import tenantDatabase from './tenant-database';
|
import tenantDatabase from './tenant-database';
|
||||||
import signup from './signup';
|
import signup from './signup';
|
||||||
@@ -14,9 +15,13 @@ import jwt from './jwt';
|
|||||||
import mail from './mail';
|
import mail from './mail';
|
||||||
import loops from './loops';
|
import loops from './loops';
|
||||||
import bankfeed from './bankfeed';
|
import bankfeed from './bankfeed';
|
||||||
|
import throttle from './throttle';
|
||||||
|
import cloud from './cloud';
|
||||||
|
|
||||||
export const config = [
|
export const config = [
|
||||||
|
app,
|
||||||
systemDatabase,
|
systemDatabase,
|
||||||
|
cloud,
|
||||||
tenantDatabase,
|
tenantDatabase,
|
||||||
signup,
|
signup,
|
||||||
gotenberg,
|
gotenberg,
|
||||||
@@ -32,4 +37,5 @@ export const config = [
|
|||||||
mail,
|
mail,
|
||||||
loops,
|
loops,
|
||||||
bankfeed,
|
bankfeed,
|
||||||
|
throttle,
|
||||||
];
|
];
|
||||||
|
|||||||
14
packages/server/src/common/config/throttle.ts
Normal file
14
packages/server/src/common/config/throttle.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('throttle', () => ({
|
||||||
|
global: {
|
||||||
|
ttl: parseInt(process.env.THROTTLE_GLOBAL_TTL ?? '60000', 10),
|
||||||
|
limit: parseInt(process.env.THROTTLE_GLOBAL_LIMIT ?? '100', 10),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
ttl: parseInt(process.env.THROTTLE_AUTH_TTL ?? '60000', 10),
|
||||||
|
limit: parseInt(process.env.THROTTLE_AUTH_LIMIT ?? '10', 10),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
55
packages/server/src/common/dtos/BulkDelete.dto.ts
Normal file
55
packages/server/src/common/dtos/BulkDelete.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { IsArray, IsInt, ArrayNotEmpty, IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { parseBoolean } from '@/utils/parse-boolean';
|
||||||
|
|
||||||
|
export class BulkDeleteDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of IDs to delete',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2, 3],
|
||||||
|
})
|
||||||
|
ids: number[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'When true, undeletable items will be skipped and only deletable ones will be removed.',
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
skipUndeletable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateBulkDeleteResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Number of items that can be deleted',
|
||||||
|
example: 2,
|
||||||
|
})
|
||||||
|
deletableCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Number of items that cannot be deleted',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
nonDeletableCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'IDs of items that can be deleted',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2],
|
||||||
|
})
|
||||||
|
deletableIds: number[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'IDs of items that cannot be deleted',
|
||||||
|
type: [Number],
|
||||||
|
example: [3],
|
||||||
|
})
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,53 +7,46 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type Observable } from 'rxjs';
|
import { type Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
import { mapKeysDeep } from '@/utils/deepdash';
|
||||||
|
|
||||||
export function camelToSnake<T = any>(value: T) {
|
export function camelToSnake<T = any>(value: T): T {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
return mapKeysDeep(
|
||||||
return value.map(camelToSnake);
|
value,
|
||||||
}
|
(_value: string, key: any, parent: any, context: any) => {
|
||||||
if (typeof value === 'object' && !(value instanceof Date)) {
|
if (Array.isArray(parent)) {
|
||||||
return Object.fromEntries(
|
// tell mapKeysDeep to skip mapping inside this branch
|
||||||
Object.entries(value).map(([key, value]) => [
|
context.skipChildren = true;
|
||||||
key
|
return key;
|
||||||
.split(/(?=[A-Z])/)
|
}
|
||||||
.join('_')
|
return key
|
||||||
.toLowerCase(),
|
.split(/(?=[A-Z])/)
|
||||||
camelToSnake(value),
|
.join('_')
|
||||||
]),
|
.toLowerCase();
|
||||||
);
|
},
|
||||||
}
|
) as T;
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snakeToCamel<T = any>(value: T) {
|
export function snakeToCamel<T = any>(value: T): T {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
return mapKeysDeep(
|
||||||
if (Array.isArray(value)) {
|
value,
|
||||||
return value.map(snakeToCamel);
|
(_value: string, key: any, parent: any, context: any) => {
|
||||||
}
|
if (Array.isArray(parent)) {
|
||||||
|
// tell mapKeysDeep to skip mapping inside this branch
|
||||||
const impl = (str: string) => {
|
context.skipChildren = true;
|
||||||
const converted = str.replace(/([-_]\w)/g, (group) =>
|
return key;
|
||||||
group[1].toUpperCase(),
|
}
|
||||||
);
|
const converted = key.replace(/([-_]\w)/g, (group) =>
|
||||||
return converted[0].toLowerCase() + converted.slice(1);
|
group[1].toUpperCase(),
|
||||||
};
|
);
|
||||||
|
return converted[0].toLowerCase() + converted.slice(1);
|
||||||
if (typeof value === 'object' && !(value instanceof Date)) {
|
},
|
||||||
return Object.fromEntries(
|
) as T;
|
||||||
Object.entries(value).map(([key, value]) => [
|
|
||||||
impl(key),
|
|
||||||
snakeToCamel(value),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_STRATEGY = {
|
export const DEFAULT_STRATEGY = {
|
||||||
@@ -63,7 +56,7 @@ export const DEFAULT_STRATEGY = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SerializeInterceptor implements NestInterceptor<any, any> {
|
export class SerializeInterceptor implements NestInterceptor<any, any> {
|
||||||
constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) {}
|
constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) { }
|
||||||
|
|
||||||
intercept(
|
intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.createTable('api_keys', (table) => {
|
||||||
|
table.increments();
|
||||||
|
table.string('key').notNullable().unique().index();
|
||||||
|
table.string('name');
|
||||||
|
table
|
||||||
|
.integer('user_id')
|
||||||
|
.unsigned()
|
||||||
|
.notNullable()
|
||||||
|
.index()
|
||||||
|
.references('id')
|
||||||
|
.inTable('users');
|
||||||
|
table
|
||||||
|
.bigInteger('tenant_id')
|
||||||
|
.unsigned()
|
||||||
|
.notNullable()
|
||||||
|
.index()
|
||||||
|
.references('id')
|
||||||
|
.inTable('tenants');
|
||||||
|
table.dateTime('expires_at').nullable().index();
|
||||||
|
table.dateTime('revoked_at').nullable().index();
|
||||||
|
table.timestamps();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.dropTableIfExists('api_keys');
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ class FsMigrations {
|
|||||||
private sortDirsSeparately: boolean;
|
private sortDirsSeparately: boolean;
|
||||||
private migrationsPaths: string[];
|
private migrationsPaths: string[];
|
||||||
private loadExtensions: string[];
|
private loadExtensions: string[];
|
||||||
|
private seedsDirectory: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor method.
|
* Constructor method.
|
||||||
@@ -30,6 +31,8 @@ class FsMigrations {
|
|||||||
}
|
}
|
||||||
this.migrationsPaths = migrationDirectories;
|
this.migrationsPaths = migrationDirectories;
|
||||||
this.loadExtensions = loadExtensions || DEFAULT_LOAD_EXTENSIONS;
|
this.loadExtensions = loadExtensions || DEFAULT_LOAD_EXTENSIONS;
|
||||||
|
// Store the seeds directory (first path is the seeds directory)
|
||||||
|
this.seedsDirectory = migrationDirectories[0] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +96,10 @@ class FsMigrations {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
public getMigration(migration: MigrateItem): string {
|
public getMigration(migration: MigrateItem): string {
|
||||||
return importWebpackSeedModule(migration.file.replace('.ts', ''));
|
return importWebpackSeedModule(
|
||||||
|
migration.file.replace('.ts', ''),
|
||||||
|
this.seedsDirectory,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detarmines the module type of the given file path.
|
* Detarmines the module type of the given file path.
|
||||||
@@ -35,8 +36,32 @@ export async function importFile(filepath: string): any {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} moduleName
|
* @param {string} moduleName
|
||||||
|
* @param {string} seedsDirectory - The seeds directory path from config
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function importWebpackSeedModule(moduleName: string): any {
|
export async function importWebpackSeedModule(
|
||||||
return import(`../../database/seeds/core/${moduleName}`);
|
moduleName: string,
|
||||||
|
seedsDirectory: string,
|
||||||
|
): any {
|
||||||
|
// Convert the seeds directory to a relative path from this file's location
|
||||||
|
const utilsDir = __dirname;
|
||||||
|
const seedsDirAbsolute = path.isAbsolute(seedsDirectory)
|
||||||
|
? seedsDirectory
|
||||||
|
: path.resolve(process.cwd(), seedsDirectory);
|
||||||
|
|
||||||
|
// Get relative path from Utils.js location to seeds directory
|
||||||
|
const relativePath = path.relative(utilsDir, seedsDirAbsolute);
|
||||||
|
|
||||||
|
// Convert to forward slashes for import (works on all platforms)
|
||||||
|
const importPath = relativePath.split(path.sep).join('/');
|
||||||
|
|
||||||
|
// Construct the import path (add ./ prefix if not already present, or handle empty/current dir)
|
||||||
|
let finalPath = importPath;
|
||||||
|
if (!finalPath || finalPath === '.') {
|
||||||
|
finalPath = './';
|
||||||
|
} else if (!finalPath.startsWith('.')) {
|
||||||
|
finalPath = `./${finalPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return import(`${finalPath}/${moduleName}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ServiceErrorFilter } from './common/filters/service-error.filter';
|
|||||||
import { ModelHasRelationsFilter } from './common/filters/model-has-relations.filter';
|
import { ModelHasRelationsFilter } from './common/filters/model-has-relations.filter';
|
||||||
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
|
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
|
||||||
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
|
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
global.__public_dirname = path.join(__dirname, '..', 'public');
|
global.__public_dirname = path.join(__dirname, '..', 'public');
|
||||||
global.__static_dirname = path.join(__dirname, '../static');
|
global.__static_dirname = path.join(__dirname, '../static');
|
||||||
@@ -15,7 +16,10 @@ global.__views_dirname = path.join(global.__static_dirname, '/views');
|
|||||||
global.__images_dirname = path.join(global.__static_dirname, '/images');
|
global.__images_dirname = path.join(global.__static_dirname, '/images');
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
rawBody: true,
|
||||||
|
});
|
||||||
|
app.set('query parser', 'extended');
|
||||||
app.setGlobalPrefix('/api');
|
app.setGlobalPrefix('/api');
|
||||||
|
|
||||||
// create and mount the middleware manually here
|
// create and mount the middleware manually here
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Query,
|
Query,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
Put,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AccountsApplication } from './AccountsApplication.service';
|
import { AccountsApplication } from './AccountsApplication.service';
|
||||||
import { CreateAccountDTO } from './CreateAccount.dto';
|
import { CreateAccountDTO } from './CreateAccount.dto';
|
||||||
import { EditAccountDTO } from './EditAccount.dto';
|
import { EditAccountDTO } from './EditAccount.dto';
|
||||||
import { IAccountsFilter } from './Accounts.types';
|
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
@@ -24,16 +25,57 @@ import { AccountResponseDto } from './dtos/AccountResponse.dto';
|
|||||||
import { AccountTypeResponseDto } from './dtos/AccountTypeResponse.dto';
|
import { AccountTypeResponseDto } from './dtos/AccountTypeResponse.dto';
|
||||||
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
|
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
|
||||||
import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto';
|
import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto';
|
||||||
|
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
} from '@/common/dtos/BulkDelete.dto';
|
||||||
|
|
||||||
@Controller('accounts')
|
@Controller('accounts')
|
||||||
@ApiTags('Accounts')
|
@ApiTags('Accounts')
|
||||||
@ApiExtraModels(AccountResponseDto)
|
@ApiExtraModels(AccountResponseDto)
|
||||||
@ApiExtraModels(AccountTypeResponseDto)
|
@ApiExtraModels(AccountTypeResponseDto)
|
||||||
@ApiExtraModels(GetAccountTransactionResponseDto)
|
@ApiExtraModels(GetAccountTransactionResponseDto)
|
||||||
|
@ApiExtraModels(ValidateBulkDeleteResponseDto)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
export class AccountsController {
|
export class AccountsController {
|
||||||
constructor(private readonly accountsApplication: AccountsApplication) {}
|
constructor(private readonly accountsApplication: AccountsApplication) { }
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Validates which accounts can be deleted and returns counts of deletable and non-deletable accounts.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed. Returns counts and IDs of deletable and non-deletable accounts.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async validateBulkDeleteAccounts(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> {
|
||||||
|
return this.accountsApplication.validateBulkDeleteAccounts(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple accounts in bulk.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The accounts have been successfully deleted.',
|
||||||
|
})
|
||||||
|
async bulkDeleteAccounts(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.accountsApplication.bulkDeleteAccounts(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create an account' })
|
@ApiOperation({ summary: 'Create an account' })
|
||||||
@@ -45,7 +87,7 @@ export class AccountsController {
|
|||||||
return this.accountsApplication.createAccount(accountDTO);
|
return this.accountsApplication.createAccount(accountDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Edit the given account.' })
|
@ApiOperation({ summary: 'Edit the given account.' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -178,7 +220,19 @@ export class AccountsController {
|
|||||||
items: { $ref: getSchemaPath(AccountResponseDto) },
|
items: { $ref: getSchemaPath(AccountResponseDto) },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
async getAccounts(@Query() filter: Partial<IAccountsFilter>) {
|
@ApiQuery({
|
||||||
|
name: 'onlyInactive',
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
description: 'Filter to show only inactive accounts',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'structure',
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
description: 'Structure type for the accounts list',
|
||||||
|
})
|
||||||
|
async getAccounts(@Query() filter: GetAccountsQueryDto) {
|
||||||
return this.accountsApplication.getAccounts(filter);
|
return this.accountsApplication.getAccounts(filter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { GetAccountsService } from './GetAccounts.service';
|
|||||||
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
||||||
import { AccountsExportable } from './AccountsExportable.service';
|
import { AccountsExportable } from './AccountsExportable.service';
|
||||||
import { AccountsImportable } from './AccountsImportable.service';
|
import { AccountsImportable } from './AccountsImportable.service';
|
||||||
|
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
|
||||||
|
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
|
||||||
|
|
||||||
const models = [RegisterTenancyModel(BankAccount)];
|
const models = [RegisterTenancyModel(BankAccount)];
|
||||||
|
|
||||||
@@ -40,7 +42,9 @@ const models = [RegisterTenancyModel(BankAccount)];
|
|||||||
GetAccountTransactionsService,
|
GetAccountTransactionsService,
|
||||||
GetAccountsService,
|
GetAccountsService,
|
||||||
AccountsExportable,
|
AccountsExportable,
|
||||||
AccountsImportable
|
AccountsImportable,
|
||||||
|
BulkDeleteAccountsService,
|
||||||
|
ValidateBulkDeleteAccountsService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AccountRepository,
|
AccountRepository,
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import { GetAccount } from './GetAccount.service';
|
|||||||
import { ActivateAccount } from './ActivateAccount.service';
|
import { ActivateAccount } from './ActivateAccount.service';
|
||||||
import { GetAccountTypesService } from './GetAccountTypes.service';
|
import { GetAccountTypesService } from './GetAccountTypes.service';
|
||||||
import { GetAccountTransactionsService } from './GetAccountTransactions.service';
|
import { GetAccountTransactionsService } from './GetAccountTransactions.service';
|
||||||
import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types';
|
import { IAccountsTransactionsFilter } from './Accounts.types';
|
||||||
import { GetAccountsService } from './GetAccounts.service';
|
import { GetAccountsService } from './GetAccounts.service';
|
||||||
import { IFilterMeta } from '@/interfaces/Model';
|
import { IFilterMeta } from '@/interfaces/Model';
|
||||||
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
|
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
|
||||||
|
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
|
||||||
|
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
|
||||||
|
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
|
||||||
|
import { ValidateBulkDeleteResponseDto } from '@/common/dtos/BulkDelete.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountsApplication {
|
export class AccountsApplication {
|
||||||
@@ -36,7 +40,9 @@ export class AccountsApplication {
|
|||||||
private readonly getAccountService: GetAccount,
|
private readonly getAccountService: GetAccount,
|
||||||
private readonly getAccountTransactionsService: GetAccountTransactionsService,
|
private readonly getAccountTransactionsService: GetAccountTransactionsService,
|
||||||
private readonly getAccountsService: GetAccountsService,
|
private readonly getAccountsService: GetAccountsService,
|
||||||
) {}
|
private readonly bulkDeleteAccountsService: BulkDeleteAccountsService,
|
||||||
|
private readonly validateBulkDeleteAccountsService: ValidateBulkDeleteAccountsService,
|
||||||
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new account.
|
* Creates a new account.
|
||||||
@@ -108,11 +114,11 @@ export class AccountsApplication {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the accounts list.
|
* Retrieves the accounts list.
|
||||||
* @param {IAccountsFilter} filterDTO - Filter DTO.
|
* @param {GetAccountsQueryDto} filterDTO - Filter DTO.
|
||||||
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
|
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
|
||||||
*/
|
*/
|
||||||
public getAccounts = (
|
public getAccounts = (
|
||||||
filterDTO: Partial<IAccountsFilter>,
|
filterDTO: GetAccountsQueryDto,
|
||||||
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> => {
|
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> => {
|
||||||
return this.getAccountsService.getAccountsList(filterDTO);
|
return this.getAccountsService.getAccountsList(filterDTO);
|
||||||
};
|
};
|
||||||
@@ -127,4 +133,28 @@ export class AccountsApplication {
|
|||||||
): Promise<Array<GetAccountTransactionResponseDto>> => {
|
): Promise<Array<GetAccountTransactionResponseDto>> => {
|
||||||
return this.getAccountTransactionsService.getAccountsTransactions(filter);
|
return this.getAccountTransactionsService.getAccountsTransactions(filter);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which accounts can be deleted in bulk.
|
||||||
|
*/
|
||||||
|
public validateBulkDeleteAccounts = (
|
||||||
|
accountIds: number[],
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> => {
|
||||||
|
return this.validateBulkDeleteAccountsService.validateBulkDeleteAccounts(
|
||||||
|
accountIds,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple accounts in bulk.
|
||||||
|
*/
|
||||||
|
public bulkDeleteAccounts = (
|
||||||
|
accountIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
): Promise<void> => {
|
||||||
|
return this.bulkDeleteAccountsService.bulkDeleteAccounts(
|
||||||
|
accountIds,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteAccount } from './DeleteAccount.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteAccountsService {
|
||||||
|
constructor(private readonly deleteAccountService: DeleteAccount) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple accounts.
|
||||||
|
* @param {number | Array<number>} accountIds - The account id or ids.
|
||||||
|
* @param {Knex.Transaction} trx - The transaction.
|
||||||
|
*/
|
||||||
|
async bulkDeleteAccounts(
|
||||||
|
accountIds: number | Array<number>,
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const accountsIds = uniq(castArray(accountIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(accountsIds)
|
||||||
|
.process(async (accountId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteAccountService.deleteAccount(accountId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export class CommandAccountValidators {
|
|||||||
@Inject(Account.name)
|
@Inject(Account.name)
|
||||||
private readonly accountModel: TenantModelProxy<typeof Account>,
|
private readonly accountModel: TenantModelProxy<typeof Account>,
|
||||||
private readonly accountRepository: AccountRepository,
|
private readonly accountRepository: AccountRepository,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws error if the account was prefined.
|
* Throws error if the account was prefined.
|
||||||
@@ -115,7 +115,7 @@ export class CommandAccountValidators {
|
|||||||
accountName: string,
|
accountName: string,
|
||||||
notAccountId?: number,
|
notAccountId?: number,
|
||||||
) {
|
) {
|
||||||
const foundAccount = await this.accountModel
|
const foundAccount = await this.accountModel()
|
||||||
.query()
|
.query()
|
||||||
.findOne('name', accountName)
|
.findOne('name', accountName)
|
||||||
.onBuild((query) => {
|
.onBuild((query) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsOptional,
|
|
||||||
IsInt,
|
IsInt,
|
||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
@@ -65,7 +65,7 @@ export class CreateAccountDTO {
|
|||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@ToNumber()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'ID of the parent account',
|
description: 'ID of the parent account',
|
||||||
example: 1,
|
example: 1,
|
||||||
|
|||||||
@@ -50,8 +50,12 @@ export class DeleteAccount {
|
|||||||
/**
|
/**
|
||||||
* Deletes the account from the storage.
|
* Deletes the account from the storage.
|
||||||
* @param {number} accountId
|
* @param {number} accountId
|
||||||
|
* @param {Knex.Transaction} trx - Database transaction instance.
|
||||||
*/
|
*/
|
||||||
public deleteAccount = async (accountId: number): Promise<void> => {
|
public deleteAccount = async (
|
||||||
|
accountId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> => {
|
||||||
// Retrieve account or not found service error.
|
// Retrieve account or not found service error.
|
||||||
const oldAccount = await this.accountModel().query().findById(accountId);
|
const oldAccount = await this.accountModel().query().findById(accountId);
|
||||||
|
|
||||||
@@ -82,6 +86,6 @@ export class DeleteAccount {
|
|||||||
oldAccount,
|
oldAccount,
|
||||||
trx,
|
trx,
|
||||||
} as IAccountEventDeletedPayload);
|
} as IAccountEventDeletedPayload);
|
||||||
});
|
}, trx);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
|
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
|
||||||
IsString,
|
|
||||||
IsOptional,
|
|
||||||
IsInt,
|
|
||||||
MinLength,
|
|
||||||
MaxLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class EditAccountDTO {
|
export class EditAccountDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -45,7 +40,7 @@ export class EditAccountDTO {
|
|||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@ToNumber()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The parent account ID of the account',
|
description: 'The parent account ID of the account',
|
||||||
example: 1,
|
example: 1,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class EditAccount {
|
|||||||
|
|
||||||
@Inject(Account.name)
|
@Inject(Account.name)
|
||||||
private readonly accountModel: TenantModelProxy<typeof Account>,
|
private readonly accountModel: TenantModelProxy<typeof Account>,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorize the account editing.
|
* Authorize the account editing.
|
||||||
@@ -85,8 +85,7 @@ export class EditAccount {
|
|||||||
// Update the account on the storage.
|
// Update the account on the storage.
|
||||||
const account = await this.accountModel()
|
const account = await this.accountModel()
|
||||||
.query(trx)
|
.query(trx)
|
||||||
.findById(accountId)
|
.updateAndFetchById(accountId, { ...accountDTO });
|
||||||
.updateAndFetch({ ...accountDTO });
|
|
||||||
|
|
||||||
// Triggers `onAccountEdited` event.
|
// Triggers `onAccountEdited` event.
|
||||||
// await this.eventEmitter.emitAsync(events.accounts.onEdited, {
|
// await this.eventEmitter.emitAsync(events.accounts.onEdited, {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Account } from './models/Account.model';
|
|||||||
import { AccountRepository } from './repositories/Account.repository';
|
import { AccountRepository } from './repositories/Account.repository';
|
||||||
import { IFilterMeta } from '@/interfaces/Model';
|
import { IFilterMeta } from '@/interfaces/Model';
|
||||||
import { TenantModelProxy } from '../System/models/TenantBaseModel';
|
import { TenantModelProxy } from '../System/models/TenantBaseModel';
|
||||||
|
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetAccountsService {
|
export class GetAccountsService {
|
||||||
@@ -18,7 +19,7 @@ export class GetAccountsService {
|
|||||||
|
|
||||||
@Inject(Account.name)
|
@Inject(Account.name)
|
||||||
private readonly accountModel: TenantModelProxy<typeof Account>,
|
private readonly accountModel: TenantModelProxy<typeof Account>,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve accounts datatable list.
|
* Retrieve accounts datatable list.
|
||||||
@@ -26,12 +27,12 @@ export class GetAccountsService {
|
|||||||
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
|
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
|
||||||
*/
|
*/
|
||||||
public async getAccountsList(
|
public async getAccountsList(
|
||||||
filterDto: Partial<IAccountsFilter>,
|
filterDto: Partial<GetAccountsQueryDto>,
|
||||||
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> {
|
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> {
|
||||||
const parsedFilterDto = {
|
const parsedFilterDto = {
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
columnSortBy: 'created_at',
|
columnSortBy: 'created_at',
|
||||||
inactiveMode: false,
|
onlyInactive: false,
|
||||||
structure: IAccountsStructureType.Tree,
|
structure: IAccountsStructureType.Tree,
|
||||||
...filterDto,
|
...filterDto,
|
||||||
};
|
};
|
||||||
@@ -48,7 +49,7 @@ export class GetAccountsService {
|
|||||||
.query()
|
.query()
|
||||||
.onBuild((builder) => {
|
.onBuild((builder) => {
|
||||||
dynamicList.buildQuery()(builder);
|
dynamicList.buildQuery()(builder);
|
||||||
builder.modify('inactiveMode', filter.inactiveMode);
|
builder.modify('inactiveMode', filterDto.onlyInactive);
|
||||||
});
|
});
|
||||||
const accountsGraph = await this.accountRepository.getDependencyGraph();
|
const accountsGraph = await this.accountRepository.getDependencyGraph();
|
||||||
|
|
||||||
@@ -58,7 +59,6 @@ export class GetAccountsService {
|
|||||||
new AccountTransformer(),
|
new AccountTransformer(),
|
||||||
{ accountsGraph, structure: parsedFilterDto.structure },
|
{ accountsGraph, structure: parsedFilterDto.structure },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: transformedAccounts,
|
accounts: transformedAccounts,
|
||||||
filterMeta: dynamicList.getResponseMeta(),
|
filterMeta: dynamicList.getResponseMeta(),
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteAccount } from './DeleteAccount.service';
|
||||||
|
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteAccountsService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteAccountService: DeleteAccount,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which accounts from the provided IDs can be deleted.
|
||||||
|
* Uses the actual deleteAccount service to validate, ensuring the same validation logic.
|
||||||
|
* Uses a transaction that is always rolled back to ensure no database changes.
|
||||||
|
* @param {number[]} accountIds - Array of account IDs to validate
|
||||||
|
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
|
||||||
|
*/
|
||||||
|
public async validateBulkDeleteAccounts(accountIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
try {
|
||||||
|
await this.deleteAccountService.deleteAccount(accountId, trx);
|
||||||
|
deletableIds.push(accountId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModelHasRelationsError) {
|
||||||
|
nonDeletableIds.push(accountId);
|
||||||
|
} else {
|
||||||
|
nonDeletableIds.push(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { parseBoolean } from '@/utils/parse-boolean';
|
||||||
|
import { IAccountsStructureType } from '../Accounts.types';
|
||||||
|
|
||||||
|
export class GetAccountsQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
|
description: 'Filter to show only inactive accounts',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => parseBoolean(value, false))
|
||||||
|
onlyInactive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: IAccountsStructureType,
|
||||||
|
description: 'Structure type for the accounts list',
|
||||||
|
default: IAccountsStructureType.Tree,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(IAccountsStructureType)
|
||||||
|
structure?: IAccountsStructureType;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import { RedisModule } from '@liaoliaots/nestjs-redis';
|
import { RedisModule } from '@liaoliaots/nestjs-redis';
|
||||||
@@ -95,6 +95,8 @@ import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.
|
|||||||
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
||||||
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
||||||
import { SocketModule } from '../Socket/Socket.module';
|
import { SocketModule } from '../Socket/Socket.module';
|
||||||
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { AppThrottleModule } from './AppThrottle.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -126,6 +128,7 @@ import { SocketModule } from '../Socket/Socket.module';
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
AppThrottleModule,
|
||||||
BullModule.forRootAsync({
|
BullModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => ({
|
useFactory: async (configService: ConfigService) => ({
|
||||||
@@ -231,6 +234,10 @@ import { SocketModule } from '../Socket/Socket.module';
|
|||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: SerializeInterceptor,
|
useClass: SerializeInterceptor,
|
||||||
|
|||||||
48
packages/server/src/modules/App/AppThrottle.module.ts
Normal file
48
packages/server/src/modules/App/AppThrottle.module.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ThrottlerModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const host = configService.get<string>('redis.host') || 'localhost';
|
||||||
|
const port = Number(configService.get<number>('redis.port') || 6379);
|
||||||
|
const password = configService.get<string>('redis.password');
|
||||||
|
const db = configService.get<number>('redis.db');
|
||||||
|
|
||||||
|
const globalTtl = configService.get<number>('throttle.global.ttl');
|
||||||
|
const globalLimit = configService.get<number>('throttle.global.limit');
|
||||||
|
const authTtl = configService.get<number>('throttle.auth.ttl');
|
||||||
|
const authLimit = configService.get<number>('throttle.auth.limit');
|
||||||
|
|
||||||
|
return {
|
||||||
|
throttlers: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
ttl: globalTtl,
|
||||||
|
limit: globalLimit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'auth',
|
||||||
|
ttl: authTtl,
|
||||||
|
limit: authLimit,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
storage: new ThrottlerStorageRedisService({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
db,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppThrottleModule { }
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -28,6 +29,7 @@ import { SystemUser } from '../System/models/SystemUser';
|
|||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@ApiExcludeController()
|
@ApiExcludeController()
|
||||||
@PublicRoute()
|
@PublicRoute()
|
||||||
|
@Throttle({ auth: {} })
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authApp: AuthenticationApplication,
|
private readonly authApp: AuthenticationApplication,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Post, Param, Get, Put } from '@nestjs/common';
|
import { Controller, Post, Param, Get, Put, Body } from '@nestjs/common';
|
||||||
import { GenerateApiKey } from './commands/GenerateApiKey.service';
|
import { GenerateApiKey } from './commands/GenerateApiKey.service';
|
||||||
import { GetApiKeysService } from './queries/GetApiKeys.service';
|
import { GetApiKeysService } from './queries/GetApiKeys.service';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
|
ApiBody,
|
||||||
|
ApiProperty,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +18,20 @@ import {
|
|||||||
ApiKeyListResponseDto,
|
ApiKeyListResponseDto,
|
||||||
ApiKeyListItemDto,
|
ApiKeyListItemDto,
|
||||||
} from './dtos/ApiKey.dto';
|
} from './dtos/ApiKey.dto';
|
||||||
|
import { IsString, MaxLength } from 'class-validator';
|
||||||
|
import { IsOptional } from '@/common/decorators/Validators';
|
||||||
|
|
||||||
|
class GenerateApiKeyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Optional name for the API key',
|
||||||
|
required: false,
|
||||||
|
example: 'My API Key',
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('api-keys')
|
@Controller('api-keys')
|
||||||
@ApiTags('Api keys')
|
@ApiTags('Api keys')
|
||||||
@@ -29,17 +45,18 @@ export class AuthApiKeysController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly getApiKeysService: GetApiKeysService,
|
private readonly getApiKeysService: GetApiKeysService,
|
||||||
private readonly generateApiKeyService: GenerateApiKey,
|
private readonly generateApiKeyService: GenerateApiKey,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Post('generate')
|
@Post('generate')
|
||||||
@ApiOperation({ summary: 'Generate a new API key' })
|
@ApiOperation({ summary: 'Generate a new API key' })
|
||||||
|
@ApiBody({ type: GenerateApiKeyDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 201,
|
status: 201,
|
||||||
description: 'The generated API key',
|
description: 'The generated API key',
|
||||||
type: ApiKeyResponseDto,
|
type: ApiKeyResponseDto,
|
||||||
})
|
})
|
||||||
async generate() {
|
async generate(@Body() body: GenerateApiKeyDto) {
|
||||||
return this.generateApiKeyService.generate();
|
return this.generateApiKeyService.generate(body.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/revoke')
|
@Put(':id/revoke')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
|
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
|
||||||
import { Controller, Get, Post } from '@nestjs/common';
|
import { Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards';
|
import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards';
|
||||||
import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard';
|
import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard';
|
||||||
import { AuthenticationApplication } from './AuthApplication.sevice';
|
import { AuthenticationApplication } from './AuthApplication.sevice';
|
||||||
@@ -18,11 +19,12 @@ import { IgnoreUserVerifiedRoute } from './guards/EnsureUserVerified.guard';
|
|||||||
@IgnoreTenantSeededRoute()
|
@IgnoreTenantSeededRoute()
|
||||||
@IgnoreTenantInitializedRoute()
|
@IgnoreTenantInitializedRoute()
|
||||||
@IgnoreUserVerifiedRoute()
|
@IgnoreUserVerifiedRoute()
|
||||||
|
@Throttle({ auth: {} })
|
||||||
export class AuthedController {
|
export class AuthedController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly getAuthedAccountService: GetAuthenticatedAccount,
|
private readonly getAuthedAccountService: GetAuthenticatedAccount,
|
||||||
private readonly authApp: AuthenticationApplication,
|
private readonly authApp: AuthenticationApplication,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Post('/signup/verify/resend')
|
@Post('/signup/verify/resend')
|
||||||
@ApiOperation({ summary: 'Resend the signup confirmation message' })
|
@ApiOperation({ summary: 'Resend the signup confirmation message' })
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { defaultTo } from 'ramda';
|
import { defaultTo } from 'ramda';
|
||||||
import { ERRORS } from '../Auth.constants';
|
import { ERRORS } from '../Auth.constants';
|
||||||
import { hashPassword } from '../Auth.utils';
|
import { hashPassword } from '../Auth.utils';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthSignupService {
|
export class AuthSignupService {
|
||||||
@@ -29,6 +30,7 @@ export class AuthSignupService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly tenantsManager: TenantsManagerService,
|
private readonly tenantsManager: TenantsManagerService,
|
||||||
|
private readonly clsService: ClsService,
|
||||||
|
|
||||||
@Inject(SystemUser.name)
|
@Inject(SystemUser.name)
|
||||||
private readonly systemUserModel: typeof SystemUser,
|
private readonly systemUserModel: typeof SystemUser,
|
||||||
@@ -70,6 +72,11 @@ export class AuthSignupService {
|
|||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
inviteAcceptedAt,
|
inviteAcceptedAt,
|
||||||
});
|
});
|
||||||
|
// Set the user in the cls service.
|
||||||
|
this.clsService.set('tenantId', user.tenantId);
|
||||||
|
this.clsService.set('userId', user.id);
|
||||||
|
this.clsService.set('organizationId', tenant.organizationId);
|
||||||
|
|
||||||
// Triggers signed up event.
|
// Triggers signed up event.
|
||||||
await this.eventEmitter.emitAsync(events.auth.signUp, {
|
await this.eventEmitter.emitAsync(events.auth.signUp, {
|
||||||
signupDTO,
|
signupDTO,
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ export class GenerateApiKey {
|
|||||||
private readonly tenancyContext: TenancyContext,
|
private readonly tenancyContext: TenancyContext,
|
||||||
@Inject(ApiKeyModel.name)
|
@Inject(ApiKeyModel.name)
|
||||||
private readonly apiKeyModel: typeof ApiKeyModel,
|
private readonly apiKeyModel: typeof ApiKeyModel,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new secure API key for the current tenant and system user.
|
* Generates a new secure API key for the current tenant and system user.
|
||||||
* The key is saved in the database and returned (only the key and id for security).
|
* The key is saved in the database and returned (only the key and id for security).
|
||||||
|
* @param {string} name - Optional name for the API key.
|
||||||
* @returns {Promise<{ key: string; id: number }>} The generated API key and its database id.
|
* @returns {Promise<{ key: string; id: number }>} The generated API key and its database id.
|
||||||
*/
|
*/
|
||||||
async generate() {
|
async generate(name?: string) {
|
||||||
const tenant = await this.tenancyContext.getTenant();
|
const tenant = await this.tenancyContext.getTenant();
|
||||||
const user = await this.tenancyContext.getSystemUser();
|
const user = await this.tenancyContext.getSystemUser();
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export class GenerateApiKey {
|
|||||||
// Save the API key to the database
|
// Save the API key to the database
|
||||||
const apiKeyRecord = await this.apiKeyModel.query().insert({
|
const apiKeyRecord = await this.apiKeyModel.query().insert({
|
||||||
key,
|
key,
|
||||||
|
name,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export class ApiKeyListItemDto {
|
|||||||
@ApiProperty({ example: 'My API Key', description: 'API key name' })
|
@ApiProperty({ example: 'My API Key', description: 'API key name' })
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'bc_1234...',
|
||||||
|
description: 'First 8 characters of the API key token',
|
||||||
|
})
|
||||||
|
token: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2024-01-01T00:00:00.000Z',
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
description: 'Creation date',
|
description: 'Creation date',
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Transformer } from '@/modules/Transformer/Transformer';
|
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||||
|
|
||||||
export class GetApiKeysTransformer extends Transformer {
|
export class GetApiKeysTransformer extends Transformer {
|
||||||
|
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return ['token'];
|
||||||
|
};
|
||||||
|
|
||||||
public excludeAttributes = (): string[] => {
|
public excludeAttributes = (): string[] => {
|
||||||
return ['tenantId'];
|
return ['tenantId'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public token(apiKey) {
|
||||||
|
return apiKey.key ? `${apiKey.key.substring(0, 8)}...` : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsPositive,
|
IsPositive,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { parseBoolean } from '@/utils/parse-boolean';
|
||||||
|
|
||||||
export class NumberFormatQueryDto {
|
export class NumberFormatQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
@@ -24,6 +25,7 @@ export class NumberFormatQueryDto {
|
|||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => parseBoolean(value, false))
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
readonly divideOn1000: boolean;
|
readonly divideOn1000: boolean;
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export class NumberFormatQueryDto {
|
|||||||
example: true,
|
example: true,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => parseBoolean(value, false))
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
readonly showZero: boolean;
|
readonly showZero: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { GetBillsService } from './queries/GetBills.service';
|
import { GetBillsService } from './queries/GetBills.service';
|
||||||
import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
|
import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
|
||||||
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
|
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
|
||||||
|
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
|
||||||
|
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
|
||||||
// import { GetBillPayments } from './queries/GetBillPayments';
|
// import { GetBillPayments } from './queries/GetBillPayments';
|
||||||
// import { GetBills } from './queries/GetBills';
|
// import { GetBills } from './queries/GetBills';
|
||||||
|
|
||||||
@@ -23,7 +25,9 @@ export class BillsApplication {
|
|||||||
private openBillService: OpenBillService,
|
private openBillService: OpenBillService,
|
||||||
private getBillsService: GetBillsService,
|
private getBillsService: GetBillsService,
|
||||||
private getBillPaymentTransactionsService: GetBillPaymentTransactionsService,
|
private getBillPaymentTransactionsService: GetBillPaymentTransactionsService,
|
||||||
) {}
|
private bulkDeleteBillsService: BulkDeleteBillsService,
|
||||||
|
private validateBulkDeleteBillsService: ValidateBulkDeleteBillsService,
|
||||||
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new bill with associated GL entries.
|
* Creates a new bill with associated GL entries.
|
||||||
@@ -53,6 +57,25 @@ export class BillsApplication {
|
|||||||
return this.deleteBillService.deleteBill(billId);
|
return this.deleteBillService.deleteBill(billId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple bills.
|
||||||
|
* @param {number[]} billIds
|
||||||
|
*/
|
||||||
|
public bulkDeleteBills(
|
||||||
|
billIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
) {
|
||||||
|
return this.bulkDeleteBillsService.bulkDeleteBills(billIds, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which bills can be deleted.
|
||||||
|
* @param {number[]} billIds
|
||||||
|
*/
|
||||||
|
public validateBulkDeleteBills(billIds: number[]) {
|
||||||
|
return this.validateBulkDeleteBillsService.validateBulkDeleteBills(billIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve bills data table list.
|
* Retrieve bills data table list.
|
||||||
* @param {IBillsFilter} billsFilter -
|
* @param {IBillsFilter} billsFilter -
|
||||||
|
|||||||
@@ -22,14 +22,51 @@ import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
|
|||||||
import { BillResponseDto } from './dtos/BillResponse.dto';
|
import { BillResponseDto } from './dtos/BillResponse.dto';
|
||||||
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
|
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
} from '@/common/dtos/BulkDelete.dto';
|
||||||
|
|
||||||
@Controller('bills')
|
@Controller('bills')
|
||||||
@ApiTags('Bills')
|
@ApiTags('Bills')
|
||||||
@ApiExtraModels(BillResponseDto)
|
@ApiExtraModels(BillResponseDto)
|
||||||
@ApiExtraModels(PaginatedResponseDto)
|
@ApiExtraModels(PaginatedResponseDto)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
|
@ApiExtraModels(ValidateBulkDeleteResponseDto)
|
||||||
export class BillsController {
|
export class BillsController {
|
||||||
constructor(private billsApplication: BillsApplication) {}
|
constructor(private billsApplication: BillsApplication) { }
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Validate which bills can be deleted and return the results.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed with counts and IDs of deletable and non-deletable bills.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
validateBulkDeleteBills(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> {
|
||||||
|
return this.billsApplication.validateBulkDeleteBills(bulkDeleteDto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple bills.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Bills deleted successfully',
|
||||||
|
})
|
||||||
|
bulkDeleteBills(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.billsApplication.bulkDeleteBills(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new bill.' })
|
@ApiOperation({ summary: 'Create a new bill.' })
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
|
|||||||
import { BillsExportable } from './commands/BillsExportable';
|
import { BillsExportable } from './commands/BillsExportable';
|
||||||
import { BillsImportable } from './commands/BillsImportable';
|
import { BillsImportable } from './commands/BillsImportable';
|
||||||
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
|
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
|
||||||
|
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
|
||||||
|
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -63,8 +65,10 @@ import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
|
|||||||
BillsExportable,
|
BillsExportable,
|
||||||
BillsImportable,
|
BillsImportable,
|
||||||
GetBillPaymentTransactionsService,
|
GetBillPaymentTransactionsService,
|
||||||
|
BulkDeleteBillsService,
|
||||||
|
ValidateBulkDeleteBillsService,
|
||||||
],
|
],
|
||||||
controllers: [BillsController],
|
controllers: [BillsController],
|
||||||
exports: [BillsExportable, BillsImportable],
|
exports: [BillsExportable, BillsImportable],
|
||||||
})
|
})
|
||||||
export class BillsModule {}
|
export class BillsModule { }
|
||||||
|
|||||||
36
packages/server/src/modules/Bills/BulkDeleteBills.service.ts
Normal file
36
packages/server/src/modules/Bills/BulkDeleteBills.service.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteBill } from './commands/DeleteBill.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteBillsService {
|
||||||
|
constructor(private readonly deleteBillService: DeleteBill) { }
|
||||||
|
|
||||||
|
async bulkDeleteBills(
|
||||||
|
billIds: number | Array<number>,
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const billsIds = uniq(castArray(billIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(billsIds)
|
||||||
|
.process(async (billId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteBillService.deleteBill(billId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteBill } from './commands/DeleteBill.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteBillsService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteBillService: DeleteBill,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public async validateBulkDeleteBills(billIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
for (const billId of billIds) {
|
||||||
|
try {
|
||||||
|
await this.deleteBillService.deleteBill(billId, trx);
|
||||||
|
deletableIds.push(billId);
|
||||||
|
} catch (error) {
|
||||||
|
nonDeletableIds.push(billId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,9 +29,10 @@ export class DeleteBill {
|
|||||||
/**
|
/**
|
||||||
* Deletes the bill with associated entries.
|
* Deletes the bill with associated entries.
|
||||||
* @param {number} billId
|
* @param {number} billId
|
||||||
|
* @param {Knex.Transaction} trx - Database transaction instance.
|
||||||
* @return {void}
|
* @return {void}
|
||||||
*/
|
*/
|
||||||
public async deleteBill(billId: number) {
|
public async deleteBill(billId: number, trx?: Knex.Transaction) {
|
||||||
// Retrieve the given bill or throw not found error.
|
// Retrieve the given bill or throw not found error.
|
||||||
const oldBill = await this.billModel()
|
const oldBill = await this.billModel()
|
||||||
.query()
|
.query()
|
||||||
@@ -75,6 +76,6 @@ export class DeleteBill {
|
|||||||
oldBill,
|
oldBill,
|
||||||
trx,
|
trx,
|
||||||
} as IBIllEventDeletedPayload);
|
} as IBIllEventDeletedPayload);
|
||||||
});
|
}, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteCreditNotesService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteCreditNoteService: DeleteCreditNoteService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async bulkDeleteCreditNotes(
|
||||||
|
creditNoteIds: number | Array<number>,
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const notesIds = uniq(castArray(creditNoteIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(notesIds)
|
||||||
|
.process(async (creditNoteId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteCreditNoteService.deleteCreditNote(creditNoteId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ import { GetCreditNotesService } from './queries/GetCreditNotes.service';
|
|||||||
import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
|
import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
|
||||||
import { GetCreditNoteState } from './queries/GetCreditNoteState.service';
|
import { GetCreditNoteState } from './queries/GetCreditNoteState.service';
|
||||||
import { GetCreditNoteService } from './queries/GetCreditNote.service';
|
import { GetCreditNoteService } from './queries/GetCreditNote.service';
|
||||||
|
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
|
||||||
|
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreditNoteApplication {
|
export class CreditNoteApplication {
|
||||||
@@ -20,8 +22,10 @@ export class CreditNoteApplication {
|
|||||||
private readonly getCreditNotePdfService: GetCreditNotePdf,
|
private readonly getCreditNotePdfService: GetCreditNotePdf,
|
||||||
private readonly getCreditNotesService: GetCreditNotesService,
|
private readonly getCreditNotesService: GetCreditNotesService,
|
||||||
private readonly getCreditNoteStateService: GetCreditNoteState,
|
private readonly getCreditNoteStateService: GetCreditNoteState,
|
||||||
private readonly getCreditNoteService: GetCreditNoteService
|
private readonly getCreditNoteService: GetCreditNoteService,
|
||||||
) {}
|
private readonly bulkDeleteCreditNotesService: BulkDeleteCreditNotesService,
|
||||||
|
private readonly validateBulkDeleteCreditNotesService: ValidateBulkDeleteCreditNotesService,
|
||||||
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new credit note.
|
* Creates a new credit note.
|
||||||
@@ -97,4 +101,30 @@ export class CreditNoteApplication {
|
|||||||
getCreditNote(creditNoteId: number) {
|
getCreditNote(creditNoteId: number) {
|
||||||
return this.getCreditNoteService.getCreditNote(creditNoteId);
|
return this.getCreditNoteService.getCreditNote(creditNoteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple credit notes.
|
||||||
|
* @param {number[]} creditNoteIds
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
bulkDeleteCreditNotes(
|
||||||
|
creditNoteIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
) {
|
||||||
|
return this.bulkDeleteCreditNotesService.bulkDeleteCreditNotes(
|
||||||
|
creditNoteIds,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which credit notes can be deleted.
|
||||||
|
* @param {number[]} creditNoteIds
|
||||||
|
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
|
||||||
|
*/
|
||||||
|
validateBulkDeleteCreditNotes(creditNoteIds: number[]) {
|
||||||
|
return this.validateBulkDeleteCreditNotesService.validateBulkDeleteCreditNotes(
|
||||||
|
creditNoteIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -11,10 +12,12 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreditNoteApplication } from './CreditNoteApplication.service';
|
import { CreditNoteApplication } from './CreditNoteApplication.service';
|
||||||
import { ICreditNotesQueryDTO } from './types/CreditNotes.types';
|
import { ICreditNotesQueryDTO } from './types/CreditNotes.types';
|
||||||
@@ -22,17 +25,23 @@ import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
|
|||||||
import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto';
|
import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto';
|
||||||
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
|
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
} from '@/common/dtos/BulkDelete.dto';
|
||||||
|
import { AcceptType } from '@/constants/accept-type';
|
||||||
|
|
||||||
@Controller('credit-notes')
|
@Controller('credit-notes')
|
||||||
@ApiTags('Credit Notes')
|
@ApiTags('Credit Notes')
|
||||||
@ApiExtraModels(CreditNoteResponseDto)
|
@ApiExtraModels(CreditNoteResponseDto)
|
||||||
@ApiExtraModels(PaginatedResponseDto)
|
@ApiExtraModels(PaginatedResponseDto)
|
||||||
|
@ApiExtraModels(ValidateBulkDeleteResponseDto)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
export class CreditNotesController {
|
export class CreditNotesController {
|
||||||
/**
|
/**
|
||||||
* @param {CreditNoteApplication} creditNoteApplication - The credit note application service.
|
* @param {CreditNoteApplication} creditNoteApplication - The credit note application service.
|
||||||
*/
|
*/
|
||||||
constructor(private creditNoteApplication: CreditNoteApplication) {}
|
constructor(private creditNoteApplication: CreditNoteApplication) { }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new credit note' })
|
@ApiOperation({ summary: 'Create a new credit note' })
|
||||||
@@ -60,8 +69,26 @@ export class CreditNotesController {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Credit note not found' })
|
@ApiResponse({ status: 404, description: 'Credit note not found' })
|
||||||
getCreditNote(@Param('id') creditNoteId: number) {
|
async getCreditNote(
|
||||||
return this.creditNoteApplication.getCreditNote(creditNoteId);
|
@Param('id') creditNoteId: number,
|
||||||
|
@Headers('accept') acceptHeader: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
if (acceptHeader.includes(AcceptType.ApplicationPdf)) {
|
||||||
|
const [pdfContent, filename] =
|
||||||
|
await this.creditNoteApplication.getCreditNotePdf(creditNoteId);
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Length': pdfContent.length,
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
});
|
||||||
|
res.status(200).send(pdfContent);
|
||||||
|
} else {
|
||||||
|
const creditNote =
|
||||||
|
await this.creditNoteApplication.getCreditNote(creditNoteId);
|
||||||
|
return creditNote;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -112,6 +139,39 @@ export class CreditNotesController {
|
|||||||
return this.creditNoteApplication.openCreditNote(creditNoteId);
|
return this.creditNoteApplication.openCreditNote(creditNoteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Validates which credit notes can be deleted and returns the results.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed with counts and IDs of deletable and non-deletable credit notes.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
validateBulkDeleteCreditNotes(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> {
|
||||||
|
return this.creditNoteApplication.validateBulkDeleteCreditNotes(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple credit notes.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Credit notes deleted successfully',
|
||||||
|
})
|
||||||
|
bulkDeleteCreditNotes(@Body() bulkDeleteDto: BulkDeleteDto): Promise<void> {
|
||||||
|
return this.creditNoteApplication.bulkDeleteCreditNotes(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete a credit note' })
|
@ApiOperation({ summary: 'Delete a credit note' })
|
||||||
@ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' })
|
@ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' })
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ import { CreditNotesImportable } from './commands/CreditNotesImportable';
|
|||||||
import { CreditNoteInventoryTransactionsSubscriber } from './subscribers/CreditNoteInventoryTransactionsSubscriber';
|
import { CreditNoteInventoryTransactionsSubscriber } from './subscribers/CreditNoteInventoryTransactionsSubscriber';
|
||||||
import { RefundSyncCreditNoteBalanceSubscriber } from './subscribers/RefundSyncCreditNoteBalanceSubscriber';
|
import { RefundSyncCreditNoteBalanceSubscriber } from './subscribers/RefundSyncCreditNoteBalanceSubscriber';
|
||||||
import { DeleteCustomerLinkedCreditSubscriber } from './subscribers/DeleteCustomerLinkedCreditSubscriber';
|
import { DeleteCustomerLinkedCreditSubscriber } from './subscribers/DeleteCustomerLinkedCreditSubscriber';
|
||||||
|
import { CreditNoteAutoSerialSubscriber } from './subscribers/CreditNoteAutoSerialSubscriber';
|
||||||
import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventoryTransactions';
|
import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventoryTransactions';
|
||||||
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
|
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
|
||||||
import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module';
|
import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module';
|
||||||
import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module';
|
import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module';
|
||||||
|
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
|
||||||
|
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -71,6 +74,9 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit
|
|||||||
CreditNoteInventoryTransactionsSubscriber,
|
CreditNoteInventoryTransactionsSubscriber,
|
||||||
RefundSyncCreditNoteBalanceSubscriber,
|
RefundSyncCreditNoteBalanceSubscriber,
|
||||||
DeleteCustomerLinkedCreditSubscriber,
|
DeleteCustomerLinkedCreditSubscriber,
|
||||||
|
CreditNoteAutoSerialSubscriber,
|
||||||
|
BulkDeleteCreditNotesService,
|
||||||
|
ValidateBulkDeleteCreditNotesService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CreateCreditNoteService,
|
CreateCreditNoteService,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteCreditNotesService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteCreditNoteService: DeleteCreditNoteService,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public async validateBulkDeleteCreditNotes(creditNoteIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
for (const creditNoteId of creditNoteIds) {
|
||||||
|
try {
|
||||||
|
await this.deleteCreditNoteService.deleteCreditNote(
|
||||||
|
creditNoteId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
deletableIds.push(creditNoteId);
|
||||||
|
} catch (error) {
|
||||||
|
nonDeletableIds.push(creditNoteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -49,9 +49,13 @@ export class DeleteCreditNoteService {
|
|||||||
/**
|
/**
|
||||||
* Deletes the given credit note transactions.
|
* Deletes the given credit note transactions.
|
||||||
* @param {number} creditNoteId
|
* @param {number} creditNoteId
|
||||||
|
* @param {Knex.Transaction} trx - Database transaction instance.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public async deleteCreditNote(creditNoteId: number): Promise<void> {
|
public async deleteCreditNote(
|
||||||
|
creditNoteId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
// Retrieve the credit note or throw not found service error.
|
// Retrieve the credit note or throw not found service error.
|
||||||
const oldCreditNote = await this.creditNoteModel()
|
const oldCreditNote = await this.creditNoteModel()
|
||||||
.query()
|
.query()
|
||||||
@@ -88,7 +92,7 @@ export class DeleteCreditNoteService {
|
|||||||
creditNoteId,
|
creditNoteId,
|
||||||
trx,
|
trx,
|
||||||
} as ICreditNoteDeletedPayload);
|
} as ICreditNoteDeletedPayload);
|
||||||
});
|
}, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import { ICreditNoteCreatedPayload } from '../types/CreditNotes.types';
|
|||||||
import { events } from '@/common/events/events';
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class CreditNoteAutoSerialSubscriber {
|
export class CreditNoteAutoSerialSubscriber {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly creditNoteIncrementService: CreditNoteAutoIncrementService,
|
private readonly creditNoteIncrementService: CreditNoteAutoIncrementService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto serial increment once credit note created.
|
* Auto serial increment once credit note created.
|
||||||
* @param {ICreditNoteCreatedPayload} payload -
|
* @param {ICreditNoteCreatedPayload} payload -
|
||||||
*/
|
*/
|
||||||
@OnEvent(events.creditNote.onCreated)
|
@OnEvent(events.creditNote.onCreated)
|
||||||
async autoSerialIncrementOnceCreated({}: ICreditNoteCreatedPayload) {
|
async autoSerialIncrementOnceCreated({ }: ICreditNoteCreatedPayload) {
|
||||||
await this.creditNoteIncrementService.incrementSerialNumber();
|
await this.creditNoteIncrementService.incrementSerialNumber();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const InitialCurrencies = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ERRORS = {
|
export const ERRORS = {
|
||||||
CURRENCY_NOT_FOUND: 'currency_not_found',
|
CURRENCY_NOT_FOUND: 'CURRENCY_NOT_FOUND',
|
||||||
CURRENCY_CODE_EXISTS: 'currency_code_exists',
|
CURRENCY_CODE_EXISTS: 'CURRENCY_CODE_EXISTS',
|
||||||
BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID',
|
BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID',
|
||||||
CANNOT_DELETE_BASE_CURRENCY: 'CANNOT_DELETE_BASE_CURRENCY',
|
CANNOT_DELETE_BASE_CURRENCY: 'CANNOT_DELETE_BASE_CURRENCY',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { DeleteCustomer } from './commands/DeleteCustomer.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteCustomersService {
|
||||||
|
constructor(private readonly deleteCustomerService: DeleteCustomer) {}
|
||||||
|
|
||||||
|
public async bulkDeleteCustomers(
|
||||||
|
customerIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const ids = uniq(castArray(customerIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(ids)
|
||||||
|
.process(async (customerId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteCustomerService.deleteCustomer(customerId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw ?? results.errors[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,6 +24,10 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
|
|||||||
import { EditCustomerDto } from './dtos/EditCustomer.dto';
|
import { EditCustomerDto } from './dtos/EditCustomer.dto';
|
||||||
import { CustomerResponseDto } from './dtos/CustomerResponse.dto';
|
import { CustomerResponseDto } from './dtos/CustomerResponse.dto';
|
||||||
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
|
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
|
||||||
|
import {
|
||||||
|
BulkDeleteCustomersDto,
|
||||||
|
ValidateBulkDeleteCustomersResponseDto,
|
||||||
|
} from './dtos/BulkDeleteCustomers.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
|
||||||
@Controller('customers')
|
@Controller('customers')
|
||||||
@@ -31,7 +35,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
|||||||
@ApiExtraModels(CustomerResponseDto)
|
@ApiExtraModels(CustomerResponseDto)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(private customersApplication: CustomersApplication) {}
|
constructor(private customersApplication: CustomersApplication) { }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Retrieves the customer details.' })
|
@ApiOperation({ summary: 'Retrieves the customer details.' })
|
||||||
@@ -109,4 +113,37 @@ export class CustomersController {
|
|||||||
openingBalanceDTO,
|
openingBalanceDTO,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Validates which customers can be deleted and returns counts of deletable and non-deletable customers.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed. Returns counts and IDs of deletable and non-deletable customers.',
|
||||||
|
schema: { $ref: getSchemaPath(ValidateBulkDeleteCustomersResponseDto) },
|
||||||
|
})
|
||||||
|
validateBulkDeleteCustomers(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
|
||||||
|
): Promise<ValidateBulkDeleteCustomersResponseDto> {
|
||||||
|
return this.customersApplication.validateBulkDeleteCustomers(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple customers in bulk.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The customers have been successfully deleted.',
|
||||||
|
})
|
||||||
|
async bulkDeleteCustomers(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.customersApplication.bulkDeleteCustomers(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { CustomersExportable } from './CustomersExportable';
|
|||||||
import { CustomersImportable } from './CustomersImportable';
|
import { CustomersImportable } from './CustomersImportable';
|
||||||
import { GetCustomers } from './queries/GetCustomers.service';
|
import { GetCustomers } from './queries/GetCustomers.service';
|
||||||
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
||||||
|
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
|
||||||
|
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenancyDatabaseModule, DynamicListModule],
|
imports: [TenancyDatabaseModule, DynamicListModule],
|
||||||
@@ -37,6 +39,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
|||||||
CustomersExportable,
|
CustomersExportable,
|
||||||
CustomersImportable,
|
CustomersImportable,
|
||||||
GetCustomers,
|
GetCustomers,
|
||||||
|
BulkDeleteCustomersService,
|
||||||
|
ValidateBulkDeleteCustomersService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CustomersModule {}
|
export class CustomersModule {}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
|
|||||||
import { EditCustomerDto } from './dtos/EditCustomer.dto';
|
import { EditCustomerDto } from './dtos/EditCustomer.dto';
|
||||||
import { GetCustomers } from './queries/GetCustomers.service';
|
import { GetCustomers } from './queries/GetCustomers.service';
|
||||||
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
|
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
|
||||||
|
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
|
||||||
|
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersApplication {
|
export class CustomersApplication {
|
||||||
@@ -22,6 +24,8 @@ export class CustomersApplication {
|
|||||||
private deleteCustomerService: DeleteCustomer,
|
private deleteCustomerService: DeleteCustomer,
|
||||||
private editOpeningBalanceService: EditOpeningBalanceCustomer,
|
private editOpeningBalanceService: EditOpeningBalanceCustomer,
|
||||||
private getCustomersService: GetCustomers,
|
private getCustomersService: GetCustomers,
|
||||||
|
private readonly bulkDeleteCustomersService: BulkDeleteCustomersService,
|
||||||
|
private readonly validateBulkDeleteCustomersService: ValidateBulkDeleteCustomersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,4 +87,20 @@ export class CustomersApplication {
|
|||||||
public getCustomers = (filterDTO: GetCustomersQueryDto) => {
|
public getCustomers = (filterDTO: GetCustomersQueryDto) => {
|
||||||
return this.getCustomersService.getCustomersList(filterDTO);
|
return this.getCustomersService.getCustomersList(filterDTO);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public bulkDeleteCustomers = (
|
||||||
|
customerIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
) => {
|
||||||
|
return this.bulkDeleteCustomersService.bulkDeleteCustomers(
|
||||||
|
customerIds,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
public validateBulkDeleteCustomers = (customerIds: number[]) => {
|
||||||
|
return this.validateBulkDeleteCustomersService.validateBulkDeleteCustomers(
|
||||||
|
customerIds,
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteCustomer } from './commands/DeleteCustomer.service';
|
||||||
|
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteCustomersService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteCustomerService: DeleteCustomer,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async validateBulkDeleteCustomers(customerIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
for (const customerId of customerIds) {
|
||||||
|
try {
|
||||||
|
await this.deleteCustomerService.deleteCustomer(customerId, trx);
|
||||||
|
deletableIds.push(customerId);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof ModelHasRelationsError ||
|
||||||
|
(error instanceof ServiceError &&
|
||||||
|
error.errorType === 'CUSTOMER_HAS_TRANSACTIONS')
|
||||||
|
) {
|
||||||
|
nonDeletableIds.push(customerId);
|
||||||
|
} else {
|
||||||
|
nonDeletableIds.push(customerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -31,12 +31,13 @@ export class DeleteCustomer {
|
|||||||
* @param {number} customerId - Customer ID.
|
* @param {number} customerId - Customer ID.
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public async deleteCustomer(customerId: number): Promise<void> {
|
public async deleteCustomer(
|
||||||
|
customerId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
// Retrieve the customer or throw not found service error.
|
// Retrieve the customer or throw not found service error.
|
||||||
const oldCustomer = await this.customerModel()
|
const query = this.customerModel().query(trx);
|
||||||
.query()
|
const oldCustomer = await query.findById(customerId).throwIfNotFound();
|
||||||
.findById(customerId)
|
|
||||||
.throwIfNotFound();
|
|
||||||
|
|
||||||
// Triggers `onCustomerDeleting` event.
|
// Triggers `onCustomerDeleting` event.
|
||||||
await this.eventPublisher.emitAsync(events.customers.onDeleting, {
|
await this.eventPublisher.emitAsync(events.customers.onDeleting, {
|
||||||
@@ -45,10 +46,10 @@ export class DeleteCustomer {
|
|||||||
} as ICustomerDeletingPayload);
|
} as ICustomerDeletingPayload);
|
||||||
|
|
||||||
// Deletes the customer and associated entities under UOW transaction.
|
// Deletes the customer and associated entities under UOW transaction.
|
||||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
return this.uow.withTransaction(async (transaction: Knex.Transaction) => {
|
||||||
// Delete the customer from the storage.
|
// Delete the customer from the storage.
|
||||||
await this.customerModel()
|
await this.customerModel()
|
||||||
.query(trx)
|
.query(transaction)
|
||||||
.findById(customerId)
|
.findById(customerId)
|
||||||
.deleteIfNoRelations({
|
.deleteIfNoRelations({
|
||||||
type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
|
type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
|
||||||
@@ -58,8 +59,8 @@ export class DeleteCustomer {
|
|||||||
await this.eventPublisher.emitAsync(events.customers.onDeleted, {
|
await this.eventPublisher.emitAsync(events.customers.onDeleted, {
|
||||||
customerId,
|
customerId,
|
||||||
oldCustomer,
|
oldCustomer,
|
||||||
trx,
|
trx: transaction,
|
||||||
} as ICustomerEventDeletedPayload);
|
} as ICustomerEventDeletedPayload);
|
||||||
});
|
}, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { parseBoolean } from '@/utils/parse-boolean';
|
||||||
|
|
||||||
|
export class BulkDeleteCustomersDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of customer IDs to delete',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2, 3],
|
||||||
|
})
|
||||||
|
ids: number[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'When true, undeletable customers will be skipped and only deletable ones removed.',
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
skipUndeletable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateBulkDeleteCustomersResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Number of customers that can be deleted',
|
||||||
|
example: 2,
|
||||||
|
})
|
||||||
|
deletableCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Number of customers that cannot be deleted',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
nonDeletableCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'IDs of customers that can be deleted',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2],
|
||||||
|
})
|
||||||
|
deletableIds: number[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'IDs of customers that cannot be deleted',
|
||||||
|
type: [Number],
|
||||||
|
example: [3],
|
||||||
|
})
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteExpense } from './commands/DeleteExpense.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteExpensesService {
|
||||||
|
constructor(private readonly deleteExpenseService: DeleteExpense) { }
|
||||||
|
|
||||||
|
async bulkDeleteExpenses(
|
||||||
|
expenseIds: number | Array<number>,
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const expensesIds = uniq(castArray(expenseIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(expensesIds)
|
||||||
|
.process(async (expenseId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteExpenseService.deleteExpense(expenseId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,13 +21,55 @@ import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
|
|||||||
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
|
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
|
||||||
import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto';
|
import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
} from '@/common/dtos/BulkDelete.dto';
|
||||||
|
|
||||||
@Controller('expenses')
|
@Controller('expenses')
|
||||||
@ApiTags('Expenses')
|
@ApiTags('Expenses')
|
||||||
@ApiExtraModels(PaginatedResponseDto, ExpenseResponseDto)
|
@ApiExtraModels(
|
||||||
|
PaginatedResponseDto,
|
||||||
|
ExpenseResponseDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
export class ExpensesController {
|
export class ExpensesController {
|
||||||
constructor(private readonly expensesApplication: ExpensesApplication) {}
|
constructor(private readonly expensesApplication: ExpensesApplication) { }
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Validate which expenses can be deleted and return the results.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed with counts and IDs of deletable and non-deletable expenses.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public validateBulkDeleteExpenses(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> {
|
||||||
|
return this.expensesApplication.validateBulkDeleteExpenses(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple expenses.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Expenses deleted successfully',
|
||||||
|
})
|
||||||
|
public bulkDeleteExpenses(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
) {
|
||||||
|
return this.expensesApplication.bulkDeleteExpenses(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new expense transaction.
|
* Create a new expense transaction.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { GetExpensesService } from './queries/GetExpenses.service';
|
|||||||
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
||||||
import { ExpensesExportable } from './ExpensesExportable';
|
import { ExpensesExportable } from './ExpensesExportable';
|
||||||
import { ExpensesImportable } from './ExpensesImportable';
|
import { ExpensesImportable } from './ExpensesImportable';
|
||||||
|
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
|
||||||
|
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LedgerModule, BranchesModule, DynamicListModule],
|
imports: [LedgerModule, BranchesModule, DynamicListModule],
|
||||||
@@ -41,6 +43,8 @@ import { ExpensesImportable } from './ExpensesImportable';
|
|||||||
GetExpensesService,
|
GetExpensesService,
|
||||||
ExpensesExportable,
|
ExpensesExportable,
|
||||||
ExpensesImportable,
|
ExpensesImportable,
|
||||||
|
BulkDeleteExpensesService,
|
||||||
|
ValidateBulkDeleteExpensesService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ExpensesModule {}
|
export class ExpensesModule {}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { GetExpenseService } from './queries/GetExpense.service';
|
|||||||
import { IExpensesFilter } from './interfaces/Expenses.interface';
|
import { IExpensesFilter } from './interfaces/Expenses.interface';
|
||||||
import { GetExpensesService } from './queries/GetExpenses.service';
|
import { GetExpensesService } from './queries/GetExpenses.service';
|
||||||
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
|
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
|
||||||
|
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
|
||||||
|
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpensesApplication {
|
export class ExpensesApplication {
|
||||||
@@ -17,7 +19,9 @@ export class ExpensesApplication {
|
|||||||
private readonly publishExpenseService: PublishExpense,
|
private readonly publishExpenseService: PublishExpense,
|
||||||
private readonly getExpenseService: GetExpenseService,
|
private readonly getExpenseService: GetExpenseService,
|
||||||
private readonly getExpensesService: GetExpensesService,
|
private readonly getExpensesService: GetExpensesService,
|
||||||
) {}
|
private readonly bulkDeleteExpensesService: BulkDeleteExpensesService,
|
||||||
|
private readonly validateBulkDeleteExpensesService: ValidateBulkDeleteExpensesService,
|
||||||
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new expense transaction.
|
* Create a new expense transaction.
|
||||||
@@ -47,6 +51,30 @@ export class ExpensesApplication {
|
|||||||
return this.deleteExpenseService.deleteExpense(expenseId);
|
return this.deleteExpenseService.deleteExpense(expenseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes expenses in bulk.
|
||||||
|
* @param {number[]} expenseIds - Expense ids.
|
||||||
|
*/
|
||||||
|
public bulkDeleteExpenses(
|
||||||
|
expenseIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
) {
|
||||||
|
return this.bulkDeleteExpensesService.bulkDeleteExpenses(
|
||||||
|
expenseIds,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which expenses can be deleted.
|
||||||
|
* @param {number[]} expenseIds - Expense ids.
|
||||||
|
*/
|
||||||
|
public validateBulkDeleteExpenses(expenseIds: number[]) {
|
||||||
|
return this.validateBulkDeleteExpensesService.validateBulkDeleteExpenses(
|
||||||
|
expenseIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes the given expense.
|
* Publishes the given expense.
|
||||||
* @param {number} expenseId - Expense id.
|
* @param {number} expenseId - Expense id.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteExpense } from './commands/DeleteExpense.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteExpensesService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteExpenseService: DeleteExpense,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public async validateBulkDeleteExpenses(expenseIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
for (const expenseId of expenseIds) {
|
||||||
|
try {
|
||||||
|
await this.deleteExpenseService.deleteExpense(expenseId, trx);
|
||||||
|
deletableIds.push(expenseId);
|
||||||
|
} catch (error) {
|
||||||
|
nonDeletableIds.push(expenseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -36,9 +36,12 @@ export class DeleteExpense {
|
|||||||
/**
|
/**
|
||||||
* Deletes the given expense.
|
* Deletes the given expense.
|
||||||
* @param {number} expenseId
|
* @param {number} expenseId
|
||||||
* @param {ISystemUser} authorizedUser
|
* @param {Knex.Transaction} trx - Database transaction instance.
|
||||||
*/
|
*/
|
||||||
public async deleteExpense(expenseId: number): Promise<void> {
|
public async deleteExpense(
|
||||||
|
expenseId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
// Retrieves the expense transaction with associated entries or
|
// Retrieves the expense transaction with associated entries or
|
||||||
// throw not found error.
|
// throw not found error.
|
||||||
const oldExpense = await this.expenseModel()
|
const oldExpense = await this.expenseModel()
|
||||||
@@ -74,6 +77,6 @@ export class DeleteExpense {
|
|||||||
oldExpense,
|
oldExpense,
|
||||||
trx,
|
trx,
|
||||||
} as IExpenseEventDeletePayload);
|
} as IExpenseEventDeletePayload);
|
||||||
});
|
}, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,30 @@ import { ITableColumn, ITableData, ITableRow } from '../types/Table.types';
|
|||||||
import { FinancialTableStructure } from './FinancialTableStructure';
|
import { FinancialTableStructure } from './FinancialTableStructure';
|
||||||
import { tableClassNames } from '../utils';
|
import { tableClassNames } from '../utils';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { TemplateInjectable } from '../../TemplateInjectable/TemplateInjectable.service';
|
|
||||||
import { ChromiumlyTenancy } from '../../ChromiumlyTenancy/ChromiumlyTenancy.service';
|
import { ChromiumlyTenancy } from '../../ChromiumlyTenancy/ChromiumlyTenancy.service';
|
||||||
|
import { renderFinancialSheetTemplateHtml } from '@bigcapital/pdf-templates';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TableSheetPdf {
|
export class TableSheetPdf {
|
||||||
/**
|
/**
|
||||||
* @param {TemplateInjectable} templateInjectable - The template injectable service.
|
|
||||||
* @param {ChromiumlyTenancy} chromiumlyTenancy - The chromiumly tenancy service.
|
* @param {ChromiumlyTenancy} chromiumlyTenancy - The chromiumly tenancy service.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly templateInjectable: TemplateInjectable,
|
|
||||||
private readonly chromiumlyTenancy: ChromiumlyTenancy,
|
private readonly chromiumlyTenancy: ChromiumlyTenancy,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the table data into a PDF format.
|
* Converts the table data into a PDF format.
|
||||||
* @param {ITableData} table - The table data to be converted.
|
* @param {ITableData} table - The table data to be converted.
|
||||||
|
* @param {string} organizationName - The organization name.
|
||||||
* @param {string} sheetName - The name of the sheet.
|
* @param {string} sheetName - The name of the sheet.
|
||||||
* @param {string} sheetDate - The date of the sheet.
|
* @param {string} sheetDate - The date of the sheet.
|
||||||
|
* @param {string} customCSS - Optional custom CSS to inject.
|
||||||
* @returns A promise that resolves with the PDF conversion result.
|
* @returns A promise that resolves with the PDF conversion result.
|
||||||
*/
|
*/
|
||||||
public async convertToPdf(
|
public async convertToPdf(
|
||||||
table: ITableData,
|
table: ITableData,
|
||||||
|
organizationName: string,
|
||||||
sheetName: string,
|
sheetName: string,
|
||||||
sheetDate: string,
|
sheetDate: string,
|
||||||
customCSS?: string,
|
customCSS?: string,
|
||||||
@@ -33,19 +34,26 @@ export class TableSheetPdf {
|
|||||||
// Prepare columns and rows for PDF conversion
|
// Prepare columns and rows for PDF conversion
|
||||||
const columns = this.tablePdfColumns(table.columns);
|
const columns = this.tablePdfColumns(table.columns);
|
||||||
const rows = this.tablePdfRows(table.rows);
|
const rows = this.tablePdfRows(table.rows);
|
||||||
|
|
||||||
const landscape = columns.length > 4;
|
const landscape = columns.length > 4;
|
||||||
|
|
||||||
// Generate HTML content from the template
|
// Generate HTML content from the React template
|
||||||
const htmlContent = await this.templateInjectable.render(
|
const htmlContent = renderFinancialSheetTemplateHtml({
|
||||||
'financial-sheet',
|
organizationName,
|
||||||
{
|
sheetName,
|
||||||
table: { rows, columns },
|
sheetDate,
|
||||||
sheetName,
|
table: {
|
||||||
sheetDate,
|
columns: columns.map((col) => ({
|
||||||
customCSS,
|
key: col.key,
|
||||||
|
label: col.label,
|
||||||
|
style: (col as any).style, // style may be added during transformation
|
||||||
|
})),
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
cells: row.cells,
|
||||||
|
classNames: (row as any).classNames,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
);
|
customCSS,
|
||||||
|
});
|
||||||
// Convert the HTML content to PDF
|
// Convert the HTML content to PDF
|
||||||
return this.chromiumlyTenancy.convertHtmlContent(htmlContent, {
|
return this.chromiumlyTenancy.convertHtmlContent(htmlContent, {
|
||||||
margins: { top: 0, bottom: 0, left: 0, right: 0 },
|
margins: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
@@ -74,7 +82,6 @@ export class TableSheetPdf {
|
|||||||
const flatNestedTree = curriedFlatNestedTree(R.__, {
|
const flatNestedTree = curriedFlatNestedTree(R.__, {
|
||||||
nestedPrefix: '<span style="padding-left: 15px;"></span>',
|
nestedPrefix: '<span style="padding-left: 15px;"></span>',
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return R.compose(tableClassNames, flatNestedTree)(rows);
|
return R.compose(tableClassNames, flatNestedTree)(rows);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class APAgingSummaryPdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedAsDate,
|
table.meta.formattedAsDate,
|
||||||
HtmlTableCss,
|
HtmlTableCss,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class ARAgingSummaryPdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCss,
|
HtmlTableCss,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class BalanceSheetPdfInjectable {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly balanceSheetTable: BalanceSheetTableInjectable,
|
private readonly balanceSheetTable: BalanceSheetTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given balance sheet table to pdf.
|
* Converts the given balance sheet table to pdf.
|
||||||
@@ -21,6 +21,7 @@ export class BalanceSheetPdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export class CashFlowStatementQueryDto extends FinancialSheetBranchesQueryDto {
|
|||||||
})
|
})
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => NumberFormatQueryDto)
|
@Type(() => NumberFormatQueryDto)
|
||||||
|
@IsOptional()
|
||||||
numberFormat: NumberFormatQueryDto;
|
numberFormat: NumberFormatQueryDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class CashflowTablePdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class CustomerBalanceSummaryPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class GeneralLedgerPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class InventoryItemDetailsQueryDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Number format for the inventory item details',
|
description: 'Number format for the inventory item details',
|
||||||
})
|
})
|
||||||
numberFormat: INumberFormatQuery;
|
numberFormat: NumberFormatQueryDto;
|
||||||
|
|
||||||
@Transform(({ value }) => parseBoolean(value, false))
|
@Transform(({ value }) => parseBoolean(value, false))
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class InventoryDetailsTablePdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class InventoryValuationSheetPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class JournalSheetPdfInjectable {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly journalSheetTable: JournalSheetTableInjectable,
|
private readonly journalSheetTable: JournalSheetTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given journal sheet table to pdf.
|
* Converts the given journal sheet table to pdf.
|
||||||
@@ -22,6 +22,7 @@ export class JournalSheetPdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { ToNumber } from '@/common/decorators/Validators';
|
import { ToNumber } from '@/common/decorators/Validators';
|
||||||
import { parseBoolean } from '@/utils/parse-boolean';
|
import { parseBoolean } from '@/utils/parse-boolean';
|
||||||
|
import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto';
|
||||||
|
|
||||||
export class ProfitLossSheetQueryDto extends FinancialSheetBranchesQueryDto {
|
export class ProfitLossSheetQueryDto extends FinancialSheetBranchesQueryDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -30,8 +32,10 @@ export class ProfitLossSheetQueryDto extends FinancialSheetBranchesQueryDto {
|
|||||||
toDate: moment.MomentInput;
|
toDate: moment.MomentInput;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number format configuration' })
|
@ApiProperty({ description: 'Number format configuration' })
|
||||||
@Type(() => Object)
|
@ValidateNested()
|
||||||
numberFormat: INumberFormatQuery;
|
@Type(() => NumberFormatQueryDto)
|
||||||
|
@IsOptional()
|
||||||
|
numberFormat: NumberFormatQueryDto;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@Transform(({ value }) => parseBoolean(value, false))
|
@Transform(({ value }) => parseBoolean(value, false))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class ProfitLossTablePdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class PurchasesByItemsPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class SalesByItemsPdfInjectable {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly salesByItemsTable: SalesByItemsTableInjectable,
|
private readonly salesByItemsTable: SalesByItemsTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the sales by items sheet in pdf format.
|
* Retrieves the sales by items sheet in pdf format.
|
||||||
@@ -23,6 +23,7 @@ export class SalesByItemsPdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class SalesTaxLiabiltiySummaryPdf {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly salesTaxLiabiltiySummaryTable: SalesTaxLiabilitySummaryTableInjectable,
|
private readonly salesTaxLiabiltiySummaryTable: SalesTaxLiabilitySummaryTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given sales tax liability summary table to pdf.
|
* Converts the given sales tax liability summary table to pdf.
|
||||||
@@ -21,6 +21,7 @@ export class SalesTaxLiabiltiySummaryPdf {
|
|||||||
);
|
);
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class TransactionsByCustomersPdf {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly transactionsByCustomersTable: TransactionsByCustomersTableInjectable,
|
private readonly transactionsByCustomersTable: TransactionsByCustomersTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the transactions by customers in PDF format.
|
* Retrieves the transactions by customers in PDF format.
|
||||||
@@ -18,6 +18,7 @@ export class TransactionsByCustomersPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class TransactionsByVendorsPdf {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly transactionsByVendorTable: TransactionsByVendorTableInjectable,
|
private readonly transactionsByVendorTable: TransactionsByVendorTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given balance sheet table to pdf.
|
* Converts the given balance sheet table to pdf.
|
||||||
@@ -21,6 +21,7 @@ export class TransactionsByVendorsPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class TrialBalanceSheetPdfInjectable {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly trialBalanceSheetTable: TrialBalanceSheetTableInjectable,
|
private readonly trialBalanceSheetTable: TrialBalanceSheetTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given trial balance sheet table to pdf.
|
* Converts the given trial balance sheet table to pdf.
|
||||||
@@ -21,6 +21,7 @@ export class TrialBalanceSheetPdfInjectable {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedDateRange,
|
table.meta.formattedDateRange,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class VendorBalanceSummaryPdf {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly vendorBalanceSummaryTable: VendorBalanceSummaryTableInjectable,
|
private readonly vendorBalanceSummaryTable: VendorBalanceSummaryTableInjectable,
|
||||||
private readonly tableSheetPdf: TableSheetPdf,
|
private readonly tableSheetPdf: TableSheetPdf,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the sales by items sheet in pdf format.
|
* Retrieves the sales by items sheet in pdf format.
|
||||||
@@ -23,6 +23,7 @@ export class VendorBalanceSummaryPdf {
|
|||||||
|
|
||||||
return this.tableSheetPdf.convertToPdf(
|
return this.tableSheetPdf.convertToPdf(
|
||||||
table.table,
|
table.table,
|
||||||
|
table.meta.organizationName,
|
||||||
table.meta.sheetName,
|
table.meta.sheetName,
|
||||||
table.meta.formattedAsDate,
|
table.meta.formattedAsDate,
|
||||||
HtmlTableCustomCss,
|
HtmlTableCustomCss,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteItemCategoriesService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteItemCategoryService: DeleteItemCategoryService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async bulkDeleteItemCategories(
|
||||||
|
itemCategoryIds: number | Array<number>,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const categoriesIds = uniq(castArray(itemCategoryIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(categoriesIds)
|
||||||
|
.process(async (itemCategoryId: number) => {
|
||||||
|
await this.deleteItemCategoryService.deleteItemCategory(itemCategoryId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteItemCategoriesService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteItemCategoryService: DeleteItemCategoryService,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async validateBulkDeleteItemCategories(itemCategoryIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
for (const itemCategoryId of itemCategoryIds) {
|
||||||
|
try {
|
||||||
|
await this.deleteItemCategoryService.deleteItemCategory(
|
||||||
|
itemCategoryId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
deletableIds.push(itemCategoryId);
|
||||||
|
} catch (error) {
|
||||||
|
nonDeletableIds.push(itemCategoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -32,9 +32,13 @@ export class DeleteItemCategoryService {
|
|||||||
/**
|
/**
|
||||||
* Deletes the given item category.
|
* Deletes the given item category.
|
||||||
* @param {number} itemCategoryId - Item category id.
|
* @param {number} itemCategoryId - Item category id.
|
||||||
|
* @param {Knex.Transaction} trx - Database transaction instance.
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public async deleteItemCategory(itemCategoryId: number) {
|
public async deleteItemCategory(
|
||||||
|
itemCategoryId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
// Retrieve item category or throw not found error.
|
// Retrieve item category or throw not found error.
|
||||||
const oldItemCategory = await this.itemCategoryModel()
|
const oldItemCategory = await this.itemCategoryModel()
|
||||||
.query()
|
.query()
|
||||||
@@ -56,7 +60,7 @@ export class DeleteItemCategoryService {
|
|||||||
itemCategoryId,
|
itemCategoryId,
|
||||||
oldItemCategory,
|
oldItemCategory,
|
||||||
} as IItemCategoryDeletedPayload);
|
} as IItemCategoryDeletedPayload);
|
||||||
});
|
}, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
41
packages/server/src/modules/Items/BulkDeleteItems.service.ts
Normal file
41
packages/server/src/modules/Items/BulkDeleteItems.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteItemService } from './DeleteItem.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteItemsService {
|
||||||
|
constructor(private readonly deleteItemService: DeleteItemService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple items.
|
||||||
|
* @param {number | Array<number>} itemIds - The item id or ids.
|
||||||
|
* @param {Knex.Transaction} trx - The transaction.
|
||||||
|
*/
|
||||||
|
async bulkDeleteItems(
|
||||||
|
itemIds: number | Array<number>,
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const itemsIds = uniq(castArray(itemIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(itemsIds)
|
||||||
|
.process(async (itemId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteItemService.deleteItem(itemId, trx);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw ?? results.errors[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ import { ItemEstimatesResponseDto } from './dtos/ItemEstimatesResponse.dto';
|
|||||||
import { ItemBillsResponseDto } from './dtos/ItemBillsResponse.dto';
|
import { ItemBillsResponseDto } from './dtos/ItemBillsResponse.dto';
|
||||||
import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto';
|
import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteItemsDto,
|
||||||
|
ValidateBulkDeleteItemsResponseDto,
|
||||||
|
} from './dtos/BulkDeleteItems.dto';
|
||||||
|
|
||||||
@Controller('/items')
|
@Controller('/items')
|
||||||
@ApiTags('Items')
|
@ApiTags('Items')
|
||||||
@@ -39,6 +43,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
|||||||
@ApiExtraModels(ItemBillsResponseDto)
|
@ApiExtraModels(ItemBillsResponseDto)
|
||||||
@ApiExtraModels(ItemEstimatesResponseDto)
|
@ApiExtraModels(ItemEstimatesResponseDto)
|
||||||
@ApiExtraModels(ItemReceiptsResponseDto)
|
@ApiExtraModels(ItemReceiptsResponseDto)
|
||||||
|
@ApiExtraModels(ValidateBulkDeleteItemsResponseDto)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
export class ItemsController extends TenantController {
|
export class ItemsController extends TenantController {
|
||||||
constructor(private readonly itemsApplication: ItemsApplicationService) {
|
constructor(private readonly itemsApplication: ItemsApplicationService) {
|
||||||
@@ -340,4 +345,37 @@ export class ItemsController extends TenantController {
|
|||||||
const itemId = parseInt(id, 10);
|
const itemId = parseInt(id, 10);
|
||||||
return this.itemsApplication.getItemReceiptsTransactions(itemId);
|
return this.itemsApplication.getItemReceiptsTransactions(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Validates which items can be deleted and returns counts of deletable and non-deletable items.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed. Returns counts and IDs of deletable and non-deletable items.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteItemsResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async validateBulkDeleteItems(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteItemsDto,
|
||||||
|
): Promise<ValidateBulkDeleteItemsResponseDto> {
|
||||||
|
return this.itemsApplication.validateBulkDeleteItems(bulkDeleteDto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple items in bulk.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The items have been successfully deleted.',
|
||||||
|
})
|
||||||
|
async bulkDeleteItems(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteItemsDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.itemsApplication.bulkDeleteItems(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
|||||||
import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module';
|
import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module';
|
||||||
import { ItemsExportable } from './ItemsExportable.service';
|
import { ItemsExportable } from './ItemsExportable.service';
|
||||||
import { ItemsImportable } from './ItemsImportable.service';
|
import { ItemsImportable } from './ItemsImportable.service';
|
||||||
|
import { BulkDeleteItemsService } from './BulkDeleteItems.service';
|
||||||
|
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -41,8 +43,10 @@ import { ItemsImportable } from './ItemsImportable.service';
|
|||||||
TransformerInjectable,
|
TransformerInjectable,
|
||||||
ItemsEntriesService,
|
ItemsEntriesService,
|
||||||
ItemsExportable,
|
ItemsExportable,
|
||||||
ItemsImportable
|
ItemsImportable,
|
||||||
|
BulkDeleteItemsService,
|
||||||
|
ValidateBulkDeleteItemsService,
|
||||||
],
|
],
|
||||||
exports: [ItemsEntriesService, ItemsExportable, ItemsImportable],
|
exports: [ItemsEntriesService, ItemsExportable, ItemsImportable],
|
||||||
})
|
})
|
||||||
export class ItemsModule {}
|
export class ItemsModule { }
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { GetItemsService } from './GetItems.service';
|
|||||||
import { IItemsFilter } from './types/Items.types';
|
import { IItemsFilter } from './types/Items.types';
|
||||||
import { EditItemDto, CreateItemDto } from './dtos/Item.dto';
|
import { EditItemDto, CreateItemDto } from './dtos/Item.dto';
|
||||||
import { GetItemsQueryDto } from './dtos/GetItemsQuery.dto';
|
import { GetItemsQueryDto } from './dtos/GetItemsQuery.dto';
|
||||||
|
import { BulkDeleteItemsService } from './BulkDeleteItems.service';
|
||||||
|
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ItemsApplicationService {
|
export class ItemsApplicationService {
|
||||||
@@ -25,7 +27,9 @@ export class ItemsApplicationService {
|
|||||||
private readonly getItemService: GetItemService,
|
private readonly getItemService: GetItemService,
|
||||||
private readonly getItemsService: GetItemsService,
|
private readonly getItemsService: GetItemsService,
|
||||||
private readonly itemTransactionsService: ItemTransactionsService,
|
private readonly itemTransactionsService: ItemTransactionsService,
|
||||||
) {}
|
private readonly bulkDeleteItemsService: BulkDeleteItemsService,
|
||||||
|
private readonly validateBulkDeleteItemsService: ValidateBulkDeleteItemsService,
|
||||||
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new item.
|
* Creates a new item.
|
||||||
@@ -134,4 +138,30 @@ export class ItemsApplicationService {
|
|||||||
async getItemReceiptsTransactions(itemId: number): Promise<any> {
|
async getItemReceiptsTransactions(itemId: number): Promise<any> {
|
||||||
return this.itemTransactionsService.getItemReceiptTransactions(itemId);
|
return this.itemTransactionsService.getItemReceiptTransactions(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which items can be deleted in bulk.
|
||||||
|
* @param {number[]} itemIds - Array of item IDs to validate
|
||||||
|
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
|
||||||
|
*/
|
||||||
|
async validateBulkDeleteItems(itemIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
return this.validateBulkDeleteItemsService.validateBulkDeleteItems(itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple items in bulk.
|
||||||
|
* @param {number[]} itemIds - Array of item IDs to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async bulkDeleteItems(
|
||||||
|
itemIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
return this.bulkDeleteItemsService.bulkDeleteItems(itemIds, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ export class ServiceError extends Error {
|
|||||||
errorType: string;
|
errorType: string;
|
||||||
message: string;
|
message: string;
|
||||||
payload: any;
|
payload: any;
|
||||||
|
httpStatus: HttpStatus;
|
||||||
|
|
||||||
constructor(errorType: string, message?: string, payload?: any) {
|
constructor(errorType: string, message?: string, payload?: any, httpStatus?: HttpStatus) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
||||||
this.errorType = errorType;
|
this.errorType = errorType;
|
||||||
this.message = message || null;
|
this.message = message || null;
|
||||||
this.payload = payload;
|
this.payload = payload;
|
||||||
|
this.httpStatus = httpStatus || HttpStatus.BAD_REQUEST;
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus(): HttpStatus {
|
getStatus(): HttpStatus {
|
||||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
return this.httpStatus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { DeleteItemService } from './DeleteItem.service';
|
||||||
|
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteItemsService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteItemService: DeleteItemService,
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantKnex: () => Knex,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates which items from the provided IDs can be deleted.
|
||||||
|
* Uses the actual deleteItem service to validate, ensuring the same validation logic.
|
||||||
|
* Uses a transaction that is always rolled back to ensure no database changes.
|
||||||
|
* @param {number[]} itemIds - Array of item IDs to validate
|
||||||
|
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
|
||||||
|
*/
|
||||||
|
public async validateBulkDeleteItems(itemIds: number[]): Promise<{
|
||||||
|
deletableCount: number;
|
||||||
|
nonDeletableCount: number;
|
||||||
|
deletableIds: number[];
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}> {
|
||||||
|
// Create a transaction that will be rolled back
|
||||||
|
const trx = await this.tenantKnex().transaction({
|
||||||
|
isolationLevel: 'read uncommitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
// Check each item to see if it can be deleted by attempting deletion in transaction
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
try {
|
||||||
|
// Attempt to delete the item using the deleteItem service with the transaction
|
||||||
|
// This will use the exact same validation logic as the actual delete
|
||||||
|
await this.deleteItemService.deleteItem(itemId, trx);
|
||||||
|
|
||||||
|
// If deletion succeeds, item is deletable
|
||||||
|
deletableIds.push(itemId);
|
||||||
|
} catch (error) {
|
||||||
|
// If error occurs, check the type of error
|
||||||
|
if (error instanceof ModelHasRelationsError) {
|
||||||
|
// Item has associated transactions/relations, cannot be deleted
|
||||||
|
nonDeletableIds.push(itemId);
|
||||||
|
} else {
|
||||||
|
// Other errors (e.g., item not found), also mark as non-deletable
|
||||||
|
nonDeletableIds.push(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always rollback the transaction to ensure no changes are persisted
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback in case of any error
|
||||||
|
await trx.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { parseBoolean } from '@/utils/parse-boolean';
|
||||||
|
|
||||||
|
export class BulkDeleteItemsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of item IDs to delete',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2, 3],
|
||||||
|
})
|
||||||
|
ids: number[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'When true, undeletable items will be skipped and only deletable ones removed.',
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
skipUndeletable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateBulkDeleteItemsResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Number of items that can be deleted',
|
||||||
|
example: 2,
|
||||||
|
})
|
||||||
|
deletableCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Number of items that cannot be deleted',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
nonDeletableCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'IDs of items that can be deleted',
|
||||||
|
type: [Number],
|
||||||
|
example: [1, 2],
|
||||||
|
})
|
||||||
|
deletableIds: number[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'IDs of items that cannot be deleted',
|
||||||
|
type: [Number],
|
||||||
|
example: [3],
|
||||||
|
})
|
||||||
|
nonDeletableIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteManualJournalService } from './commands/DeleteManualJournal.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteManualJournalsService {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteManualJournalService: DeleteManualJournalService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async bulkDeleteManualJournals(
|
||||||
|
manualJournalIds: number | Array<number>,
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipUndeletable = false } = options ?? {};
|
||||||
|
const journalsIds = uniq(castArray(manualJournalIds));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(journalsIds)
|
||||||
|
.process(async (manualJournalId: number) => {
|
||||||
|
try {
|
||||||
|
await this.deleteManualJournalService.deleteManualJournal(
|
||||||
|
manualJournalId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!skipUndeletable) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!skipUndeletable && results.errors && results.errors.length > 0) {
|
||||||
|
throw results.errors[0].raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -25,13 +25,54 @@ import {
|
|||||||
import { IManualJournalsFilter } from './types/ManualJournals.types';
|
import { IManualJournalsFilter } from './types/ManualJournals.types';
|
||||||
import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto';
|
import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto';
|
||||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
} from '@/common/dtos/BulkDelete.dto';
|
||||||
|
|
||||||
@Controller('manual-journals')
|
@Controller('manual-journals')
|
||||||
@ApiTags('Manual Journals')
|
@ApiTags('Manual Journals')
|
||||||
@ApiExtraModels(ManualJournalResponseDto)
|
@ApiExtraModels(ManualJournalResponseDto)
|
||||||
|
@ApiExtraModels(ValidateBulkDeleteResponseDto)
|
||||||
@ApiCommonHeaders()
|
@ApiCommonHeaders()
|
||||||
export class ManualJournalsController {
|
export class ManualJournalsController {
|
||||||
constructor(private manualJournalsApplication: ManualJournalsApplication) {}
|
constructor(private manualJournalsApplication: ManualJournalsApplication) { }
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Validate which manual journals can be deleted and return the results.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed with counts and IDs of deletable and non-deletable manual journals.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public validateBulkDeleteManualJournals(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> {
|
||||||
|
return this.manualJournalsApplication.validateBulkDeleteManualJournals(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple manual journals.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Manual journals deleted successfully',
|
||||||
|
})
|
||||||
|
public bulkDeleteManualJournals(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.manualJournalsApplication.bulkDeleteManualJournals(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new manual journal.' })
|
@ApiOperation({ summary: 'Create a new manual journal.' })
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { ManualJournalsExportable } from './commands/ManualJournalExportable';
|
|||||||
import { ManualJournalImportable } from './commands/ManualJournalsImport';
|
import { ManualJournalImportable } from './commands/ManualJournalsImport';
|
||||||
import { GetManualJournals } from './queries/GetManualJournals.service';
|
import { GetManualJournals } from './queries/GetManualJournals.service';
|
||||||
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
||||||
|
import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service';
|
||||||
|
import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BranchesModule, LedgerModule, DynamicListModule],
|
imports: [BranchesModule, LedgerModule, DynamicListModule],
|
||||||
@@ -41,6 +43,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
|||||||
ManualJournalWriteGLSubscriber,
|
ManualJournalWriteGLSubscriber,
|
||||||
ManualJournalsExportable,
|
ManualJournalsExportable,
|
||||||
ManualJournalImportable,
|
ManualJournalImportable,
|
||||||
|
BulkDeleteManualJournalsService,
|
||||||
|
ValidateBulkDeleteManualJournalsService,
|
||||||
],
|
],
|
||||||
exports: [ManualJournalsExportable, ManualJournalImportable],
|
exports: [ManualJournalsExportable, ManualJournalImportable],
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user