@silexpert/core 2.0.123-beta.0 → 2.0.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/{index-BTRchHCY.d.mts → index-CL2xcnsB.d.mts} +77 -6
  2. package/dist/index-CL2xcnsB.d.mts.map +1 -0
  3. package/dist/index.d.mts +2 -2
  4. package/dist/index.mjs +6 -6
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{product-B35JCiip.mjs → product-dgz1Fsg6.mjs} +2 -2
  7. package/dist/product-dgz1Fsg6.mjs.map +1 -0
  8. package/dist/server/index.d.mts +1 -1
  9. package/dist/server/index.d.mts.map +1 -1
  10. package/dist/server/index.mjs +17 -17
  11. package/dist/server/index.mjs.map +1 -1
  12. package/package.json +1 -1
  13. package/ts/api/resources/Mfa.ts +8 -4
  14. package/ts/api/resources/zendesk/Ticket.ts +12 -3
  15. package/ts/types/Enum/PrestationType.ts +1 -1
  16. package/ts/types/Enum/Zendesk.ts +28 -0
  17. package/ts/types/Interface/api/authentification/AuthenticationResponse.ts +2 -0
  18. package/ts/types/Interface/api/authentification/ReadAuthenticateCollaborater.ts +8 -1
  19. package/ts/types/Interface/api/mfa/VerifyCode.ts +6 -1
  20. package/ts/types/Interface/api/mfa/VerifyRecoveryCode.ts +6 -1
  21. package/ts/types/Interface/api/zendesk/QueryTicket.ts +29 -1
  22. package/ts/types/Interface/api/zendesk/ReadTicket.ts +15 -0
  23. package/ts/types/Interface/models/IApiError.ts +5 -0
  24. package/ts/types/Interface/models/IZendeskTicketRead.ts +15 -0
  25. package/ts/types/index.ts +1 -0
  26. package/ts/utils/commercialManagement/document.ts +80 -22
  27. package/ts/utils/commercialManagement/templates/body.ts +1 -1
  28. package/ts/utils/commercialManagement/templates/common.ts +2 -2
  29. package/ts/utils/email.ts +26 -21
  30. package/ts/utils/isHtml.ts +14 -0
  31. package/ts/utils/tests/document.spec.ts +42 -2
  32. package/ts/utils/transforms/array.transform.ts +35 -0
  33. package/dist/index-BTRchHCY.d.mts.map +0 -1
  34. package/dist/product-B35JCiip.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silexpert/core",
3
- "version": "2.0.123-beta.0",
3
+ "version": "2.0.124",
4
4
  "description": "Silex Core perform HTTP Request and object binding. Silex core helps developers to retrieve and return formatted object",
5
5
  "keywords": [
6
6
  "accounting",
@@ -14,12 +14,16 @@ export class UserMfa extends Resource {
14
14
  return this.axios.$post('/mfa/disable', { userId });
15
15
  }
16
16
 
17
- verifyCode(userId: number, code: string): Promise<{ success: boolean; token?: string }> {
18
- return this.axios.$post('/mfa/verify/code', { userId, code });
17
+ verifyCode(userId: number, code: string, mfaChallengeToken?: string): Promise<{ success: boolean; token?: string }> {
18
+ return this.axios.$post('/mfa/verify/code', { userId, code, mfaChallengeToken });
19
19
  }
20
20
 
21
- verifyRecoveryCode(userId: number, recoveryCode: string): Promise<{ success: boolean; token?: string }> {
22
- return this.axios.$post('/mfa/verify/recovery-code', { userId, recoveryCode });
21
+ verifyRecoveryCode(
22
+ userId: number,
23
+ recoveryCode: string,
24
+ mfaChallengeToken?: string,
25
+ ): Promise<{ success: boolean; token?: string }> {
26
+ return this.axios.$post('/mfa/verify/recovery-code', { userId, recoveryCode, mfaChallengeToken });
23
27
  }
24
28
 
25
29
  isMfaEnabled(userId?: number): Promise<{ isEnabled: boolean }> {
@@ -1,16 +1,17 @@
1
1
  import {
2
+ CountUnreadZendeskTickets,
3
+ CountUnreadZendeskTicketsResponse,
2
4
  CreateZendeskTicket,
3
5
  UpdateZendeskTicket,
4
6
  ListZendeskTickets,
5
- TicketZendeskResponse,
6
- SearchZendeskResponse,
7
+ ListZendeskTicketsResponse,
7
8
  TicketFormEnriched,
8
9
  TicketZendesk,
9
10
  } from '../../../index.js';
10
11
  import { Resource } from '../../Resource.js';
11
12
 
12
13
  export class ZendeskTicket extends Resource {
13
- getPaginate(query: ListZendeskTickets): Promise<TicketZendeskResponse | SearchZendeskResponse> {
14
+ getPaginate(query: ListZendeskTickets): Promise<ListZendeskTicketsResponse> {
14
15
  return this.axios.$get('connectors/zendesk/tickets', { params: query });
15
16
  }
16
17
 
@@ -18,6 +19,10 @@ export class ZendeskTicket extends Resource {
18
19
  return this.axios.$get('connectors/zendesk/tickets/forms');
19
20
  }
20
21
 
22
+ getUnreadCount(query: CountUnreadZendeskTickets): Promise<CountUnreadZendeskTicketsResponse> {
23
+ return this.axios.$get('connectors/zendesk/tickets/unread-count', { params: query });
24
+ }
25
+
21
26
  get(id: number): Promise<TicketZendesk> {
22
27
  return this.axios.$get(`connectors/zendesk/tickets/${id}`);
23
28
  }
@@ -43,6 +48,10 @@ export class ZendeskTicket extends Resource {
43
48
  return this.axios.$put(`connectors/zendesk/tickets/${id}`, data);
44
49
  }
45
50
 
51
+ markAsRead(id: number): Promise<void> {
52
+ return this.axios.$put(`connectors/zendesk/tickets/${id}/read`);
53
+ }
54
+
46
55
  delete(id: number): Promise<void> {
47
56
  return this.axios.$delete(`connectors/zendesk/tickets/${id}`);
48
57
  }
@@ -230,7 +230,6 @@ export const allPrestationsWithBrochure = [
230
230
  PrestationType.COMPTA_POURCENTAGE_PREMIUM,
231
231
  PrestationType.COMPTA_POURCENTAGE_EXPRESS,
232
232
  PrestationType.COMPTA_POURCENTAGE_PREMIUM_EXPRESS,
233
- PrestationType.BILAN_PREVISIONNEL,
234
233
  PrestationType.EXCEPTIONNEL,
235
234
  PrestationType.DECLARATION_IR,
236
235
  ];
@@ -276,6 +275,7 @@ export const allPrestations2025WithCGV = [
276
275
  PrestationType.ATTESTATION_MASSE_SALARIALE,
277
276
  PrestationType.ATTESTATION_1,
278
277
  PrestationType.ATTESTATION_NON_REMUNERATION_RECURRENTE,
278
+ PrestationType.BILAN_PREVISIONNEL,
279
279
  ];
280
280
 
281
281
  export const ClementineSubscriptionAccountancyExpertPrestations: PrestationType[] = [
@@ -125,3 +125,31 @@ export const ZendeskTicketCategoryList: Array<{ id: ZendeskTicketCategoryEnum; l
125
125
  { id: ZendeskTicketCategoryEnum.RELANCE_BILAN, label: 'Relance bilan' },
126
126
  { id: ZendeskTicketCategoryEnum.SUPPORT_APPLICATION, label: 'Support application' },
127
127
  ];
128
+
129
+ export type ZendeskTicketStatusGroup = 'information_required' | 'in_progress' | 'received' | 'resolved' | 'closed';
130
+
131
+ export enum ZendeskTicketStatusGroupEnum {
132
+ INFORMATION_REQUIRED = 'information_required',
133
+ IN_PROGRESS = 'in_progress',
134
+ RECEIVED = 'received',
135
+ RESOLVED = 'resolved',
136
+ CLOSED = 'closed',
137
+ }
138
+
139
+ export const ZendeskTicketStatusGroupByStatus: Record<ZendeskTicketStatusEnum, ZendeskTicketStatusGroupEnum> = {
140
+ [ZendeskTicketStatusEnum.NEW]: ZendeskTicketStatusGroupEnum.RECEIVED,
141
+ [ZendeskTicketStatusEnum.OPEN]: ZendeskTicketStatusGroupEnum.IN_PROGRESS,
142
+ [ZendeskTicketStatusEnum.HOLD]: ZendeskTicketStatusGroupEnum.IN_PROGRESS,
143
+ [ZendeskTicketStatusEnum.PENDING]: ZendeskTicketStatusGroupEnum.INFORMATION_REQUIRED,
144
+ [ZendeskTicketStatusEnum.SOLVED]: ZendeskTicketStatusGroupEnum.RESOLVED,
145
+ [ZendeskTicketStatusEnum.CLOSED]: ZendeskTicketStatusGroupEnum.CLOSED,
146
+ };
147
+
148
+ // Display order of the support page: groups needing customer action come first.
149
+ export const ZendeskTicketStatusGroupList: Array<{ id: ZendeskTicketStatusGroupEnum; label: string }> = [
150
+ { id: ZendeskTicketStatusGroupEnum.INFORMATION_REQUIRED, label: 'Information requise' },
151
+ { id: ZendeskTicketStatusGroupEnum.IN_PROGRESS, label: 'En cours' },
152
+ { id: ZendeskTicketStatusGroupEnum.RECEIVED, label: 'Demande reçue' },
153
+ { id: ZendeskTicketStatusGroupEnum.RESOLVED, label: 'Résolu' },
154
+ { id: ZendeskTicketStatusGroupEnum.CLOSED, label: 'Archivé' },
155
+ ];
@@ -9,6 +9,8 @@ export class IAuthenticationResponse {
9
9
  expiresAt?: Date;
10
10
  /** ID de l'utilisateur */
11
11
  userId?: number;
12
+ /** Jeton opaque de défi MFA lié à l'étape mot de passe */
13
+ mfaChallengeToken?: string;
12
14
  /** Message informatif */
13
15
  message?: string;
14
16
  /** Token JWT si authentification réussie */
@@ -3,7 +3,14 @@ import { PrestationTypeDetail } from '../../../Enum/PrestationType.js';
3
3
  import { IUser } from '../../models/IUser.js';
4
4
 
5
5
  export class ReadAuthenticateCollaborater {
6
- token: string;
6
+ /** JWT, absent tant que la vérification MFA n'est pas faite */
7
+ token?: string;
8
+ /** Vrai si un code MFA doit être vérifié via /mfa/verify/* avant d'obtenir le JWT */
9
+ requiresMfa?: boolean;
10
+ /** ID de l'utilisateur, à renvoyer avec le code MFA */
11
+ userId?: number;
12
+ /** Jeton opaque de défi MFA lié à l'étape mot de passe */
13
+ mfaChallengeToken?: string;
7
14
  }
8
15
 
9
16
  export class ReadConnectAs {
@@ -1,4 +1,4 @@
1
- import { IsInt, IsNotEmpty, IsString, Length } from 'class-validator';
1
+ import { IsInt, IsNotEmpty, IsOptional, IsString, Length } from 'class-validator';
2
2
 
3
3
  export class MfaVerifyCode {
4
4
  @IsNotEmpty()
@@ -8,4 +8,9 @@ export class MfaVerifyCode {
8
8
  @IsString()
9
9
  @Length(6, 6)
10
10
  code: string;
11
+
12
+ /** Optionnel le temps de la transition des anciennes versions de l'app mobile (SEC-04) */
13
+ @IsOptional()
14
+ @IsString()
15
+ mfaChallengeToken?: string;
11
16
  }
@@ -1,4 +1,4 @@
1
- import { IsInt, IsNotEmpty, IsString } from 'class-validator';
1
+ import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
2
2
 
3
3
  export class MfaVerifyRecoveryCode {
4
4
  @IsNotEmpty()
@@ -8,4 +8,9 @@ export class MfaVerifyRecoveryCode {
8
8
  @IsString()
9
9
  @IsNotEmpty()
10
10
  recoveryCode: string;
11
+
12
+ /** Optionnel le temps de la transition des anciennes versions de l'app mobile (SEC-04) */
13
+ @IsOptional()
14
+ @IsString()
15
+ mfaChallengeToken?: string;
11
16
  }
@@ -10,6 +10,7 @@ import {
10
10
  Min,
11
11
  Max,
12
12
  IsDateString,
13
+ IsBoolean,
13
14
  } from 'class-validator';
14
15
  import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
15
16
  import {
@@ -21,9 +22,13 @@ import {
21
22
  ZendeskTicketPriorityEnum,
22
23
  type ZendeskTicketStatus,
23
24
  ZendeskTicketStatusEnum,
25
+ type ZendeskTicketStatusGroup,
26
+ ZendeskTicketStatusGroupEnum,
24
27
  type ZendeskTicketType,
25
28
  ZendeskTicketTypeEnum,
26
29
  } from '../../../../types/Enum/Zendesk.js';
30
+ import { ToBoolean } from '../../../../utils/transforms/boolean.transform.js';
31
+ import { ToStringArray } from '../../../../utils/transforms/array.transform.js';
27
32
 
28
33
  export class CreateZendeskTicket {
29
34
  @ApiProperty()
@@ -193,13 +198,36 @@ export class ListZendeskTickets {
193
198
  @IsDateString()
194
199
  endDate?: string;
195
200
 
201
+ /**
202
+ * @deprecated Tickets are now sorted by status group, unread state and last public message date.
203
+ * Only `sortOrder` is taken into account.
204
+ */
196
205
  @ApiPropertyOptional()
197
206
  @IsOptional()
198
207
  @IsString()
199
208
  sortBy?: string;
200
209
 
201
- @ApiPropertyOptional()
210
+ @ApiPropertyOptional({ description: 'Order applied to the last public message date' })
202
211
  @IsOptional()
203
212
  @IsEnum(['asc', 'desc'])
204
213
  sortOrder?: 'asc' | 'desc';
214
+
215
+ @ApiPropertyOptional({ enum: ZendeskTicketStatusGroupEnum, isArray: true })
216
+ @IsOptional()
217
+ @ToStringArray()
218
+ @IsArray()
219
+ @IsEnum(ZendeskTicketStatusGroupEnum, { each: true })
220
+ statusGroups?: ZendeskTicketStatusGroup[];
221
+
222
+ @ApiPropertyOptional()
223
+ @IsOptional()
224
+ @ToBoolean()
225
+ @IsBoolean()
226
+ isUnread?: boolean;
227
+ }
228
+
229
+ export class CountUnreadZendeskTickets {
230
+ @ApiProperty()
231
+ @IsEnum(ZendeskTicketChannelEnum)
232
+ channel: ZendeskTicketChannel;
205
233
  }
@@ -2,8 +2,10 @@ import {
2
2
  ZendeskTicketChannel,
3
3
  ZendeskTicketPriority,
4
4
  ZendeskTicketStatus,
5
+ ZendeskTicketStatusGroup,
5
6
  ZendeskTicketType,
6
7
  } from '../../../Enum/Zendesk.js';
8
+ import type { BasePaginate } from '../BasePaginate.js';
7
9
 
8
10
  export interface TicketZendesk {
9
11
  id: number;
@@ -22,6 +24,19 @@ export interface TicketZendesk {
22
24
  organizationId: number | null;
23
25
  createdAt: string;
24
26
  updatedAt: string;
27
+ // Read-tracking data, only computed on the paginated list
28
+ statusGroup?: ZendeskTicketStatusGroup;
29
+ isUnread?: boolean;
30
+ lastActivityAt?: string;
31
+ }
32
+
33
+ export interface ListZendeskTicketsResponse extends BasePaginate<TicketZendesk> {
34
+ // Unread tickets of the channel whatever the filters, to refresh the unread badge with the list.
35
+ unreadCount: number;
36
+ }
37
+
38
+ export interface CountUnreadZendeskTicketsResponse {
39
+ count: number;
25
40
  }
26
41
 
27
42
  export interface SearchZendeskResponse {
@@ -8,6 +8,10 @@ export enum Code {
8
8
  // HTTP Error
9
9
  tokenFailure = 'token_authentication_failure',
10
10
  authenticationFailure = 'authentication_credentials_failure',
11
+ /** MFA challenge missing, expired, used or bound to another user: the user must log in again */
12
+ mfaChallengeInvalid = 'mfa_challenge_invalid',
13
+ /** Too many wrong MFA codes for the same challenge: the user must log in again */
14
+ mfaTooManyAttempts = 'mfa_too_many_attempts',
11
15
  notFound = 'resource_not_found',
12
16
  forbidden = 'forbidden',
13
17
  fileNotFound = 'file_not_found',
@@ -23,6 +27,7 @@ export enum Code {
23
27
  CONNECTION_ALREADY_EXIST = 'connection_already_exist',
24
28
  firstConnectionFromVpn = 'first_connection_from_vpn',
25
29
  ENTITY_ALREADY_EXIST = 'entity_already_exist',
30
+ tooManyRequest = 'too_many_requests',
26
31
 
27
32
  // Annotation Error
28
33
  failedCheckBalance = 'failed_check_balance',
@@ -0,0 +1,15 @@
1
+ import type { IUser } from './IUser.js';
2
+
3
+ export interface IZendeskTicketRead {
4
+ id?: number;
5
+ idUser: number;
6
+ idTicket: number;
7
+ readAt?: Date | null;
8
+ // Activity snapshot of the Zendesk ticket, refreshed only when `ticketUpdatedAt` changes
9
+ ticketUpdatedAt?: Date | null;
10
+ lastPublicCommentAt?: Date | null;
11
+ lastSupportCommentAt?: Date | null;
12
+ createdAt?: Date;
13
+ updatedAt?: Date;
14
+ user?: IUser;
15
+ }
package/ts/types/index.ts CHANGED
@@ -290,6 +290,7 @@ export * from './Interface/models/IRevenueSnapshot.js';
290
290
  export * from './Interface/models/IEInvoicingEligibilityRequest.js';
291
291
  export * from './Interface/models/IUsersHistory.js';
292
292
  export * from './Interface/api/teamSystemSignature/index.js';
293
+ export * from './Interface/models/IZendeskTicketRead.js';
293
294
  export * from './Interface/models/IEInvoicingKycAndMandate.js';
294
295
  export * from './Interface/models/IConnectorTracking.js';
295
296
  export * from './Interface/models/IEInvoicing.js';
@@ -31,6 +31,7 @@ import ejs from 'ejs';
31
31
  import 'dayjs/locale/fr.js';
32
32
  import { orderBy, groupBy, sumBy, round, uniq } from 'lodash-es';
33
33
  import sanitize from 'sanitize-html';
34
+ import { isHtmlEmpty } from '../isHtml.js';
34
35
 
35
36
  type brandColorType = {
36
37
  primary: string;
@@ -89,7 +90,7 @@ export function generateCommercialDocument(
89
90
  paymentLink: payload.paymentLink ?? '#',
90
91
  idEstimateInvoiceType,
91
92
  title: payload.title ?? null,
92
- cgv: payload.cgv ?? null,
93
+ cgv: isHtmlEmpty(payload.cgv) ? null : payload.cgv,
93
94
  // EJS `with` blocks throw a ReferenceError when a variable key is entirely absent from the data object, so seller
94
95
  // identity/address fields must always be defined even when the payload omits them.
95
96
  name: payload.name ?? null,
@@ -171,28 +172,85 @@ export function generateCommercialDocument(
171
172
  : payload?.settings?.freeInvoiceText;
172
173
 
173
174
  return {
174
- header: ejs.render(commonTemplate.header, data),
175
- body: ejs.render(bodyTemplate, {
176
- ...data,
177
- comment:
178
- data.comment && typeof data.comment === 'string'
179
- ? sanitize(data.comment?.replace(/\n/g, '<br/>'), { allowedTags: ['b', 'i', 'em', 'br', 'strong'] })
180
- : null,
181
- products: data.products.map((p) => ({
182
- ...p,
175
+ header: ejs.render(commonTemplate.header, data, {}),
176
+ body: ejs.render(
177
+ bodyTemplate,
178
+ {
179
+ ...data,
180
+ cgv:
181
+ data.cgv && typeof data.cgv === 'string'
182
+ ? sanitize(data.cgv, {
183
+ allowedTags: [
184
+ 'b',
185
+ 'i',
186
+ 'em',
187
+ 'br',
188
+ 'strong',
189
+ 'p',
190
+ 'span',
191
+ 'li',
192
+ 'ul',
193
+ 'ol',
194
+ 'h1',
195
+ 'h2',
196
+ 'h3',
197
+ 'u',
198
+ 's',
199
+ 'a',
200
+ 'blockquote',
201
+ ],
202
+ allowedAttributes: {
203
+ a: ['href', 'name', 'target', 'data-list'],
204
+ li: ['data-list'],
205
+ '*': ['class', 'style', 'dir'],
206
+ },
207
+ allowedClasses: {
208
+ '*': [/^ql-/],
209
+ },
210
+ allowedStyles: {
211
+ '*': {
212
+ color: [/^[a-z]+$/i, /^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/i],
213
+ 'background-color': [
214
+ /^[a-z]+$/i,
215
+ /^#(?:[0-9a-f]{3}){1,2}$/i,
216
+ /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/i,
217
+ ],
218
+ 'text-align': [/^left$/i, /^right$/i, /^center$/i, /^justify$/i],
219
+ 'white-space': [/^pre-wrap$/i],
220
+ },
221
+ },
222
+ })
223
+ : null,
183
224
  comment:
184
- p.comment && typeof p.comment === 'string'
185
- ? sanitize(p.comment?.replace(/\n/g, '<br/>'), { allowedTags: ['b', 'i', 'em', 'br', 'strong'] })
225
+ data.comment && typeof data.comment === 'string'
226
+ ? sanitize(data.comment?.replace(/\n/g, '<br/>'), { allowedTags: ['b', 'i', 'em', 'br', 'strong'] })
186
227
  : null,
187
- // @ts-expect-error
188
- unitFormatted: internationalizationConsistency(locale, p.unitFormatted, 'unit'),
189
- })),
190
- vats: forcedVats ?? vats,
191
- }),
192
- footer: ejs.render(commonTemplate.footer, {
193
- ...data,
194
- freeText: documentFreeText ?? null,
195
- }),
228
+ products: data.products.map((p) => ({
229
+ ...p,
230
+ comment:
231
+ p.comment && typeof p.comment === 'string'
232
+ ? sanitize(p.comment?.replace(/\n/g, '<br/>'), { allowedTags: ['b', 'i', 'em', 'br', 'strong'] })
233
+ : null,
234
+ // @ts-expect-error
235
+ unitFormatted: internationalizationConsistency(locale, p.unitFormatted, 'unit'),
236
+ })),
237
+ vats: forcedVats ?? vats,
238
+ },
239
+ {},
240
+ ),
241
+ footer: ejs.render(
242
+ commonTemplate.footer,
243
+ {
244
+ ...data,
245
+ freeText:
246
+ documentFreeText && typeof documentFreeText === 'string'
247
+ ? sanitize(documentFreeText?.replace(/\n/g, '<br/>'), {
248
+ allowedTags: ['b', 'i', 'em', 'br', 'strong', 'p'],
249
+ })
250
+ : null,
251
+ },
252
+ {},
253
+ ),
196
254
  data,
197
255
  };
198
256
  }
@@ -227,7 +285,7 @@ export function generateMailHeader(
227
285
  const mailTemplate = new CommercialDocumentMailHTML();
228
286
 
229
287
  return {
230
- header: ejs.render(mailTemplate.header, data),
288
+ header: ejs.render(mailTemplate.header, data, {}),
231
289
  };
232
290
  }
233
291
 
@@ -120,7 +120,7 @@ export const bodyTemplate = `<html class="pdf-content">
120
120
  <br/><%= languageTemplate.template.common.options.approval %> : <br/><br/><br/><br/>
121
121
  <% } %>
122
122
 
123
- <% if (cgv && (settings && settings.invoiceCgv)) { %>
123
+ <% if (cgv && settings && settings.invoiceCgv) { %>
124
124
  <br><div style="page-break-after:always;"></div>
125
125
  <div id="WYSIWYG"><%- cgv %></div>
126
126
  <% } %>
@@ -334,7 +334,7 @@ export class CommercialDocumentMailHTML {
334
334
  </style>
335
335
  <div style="background-color: <%= brandColors.primary50 %> !important; color: black; width: calc(100% - 48px); display: grid; border-radius: 16px; padding: 24px;">
336
336
  <div style="width: max-content; margin: auto; font-size: 16px; font-family: 'circular std', sans-serif; font-weight: 100; line-height: 140%; position: relative;">
337
- <div style="color: black;"><span style="font-weight: 500;"><%- companyName %></span> <%= languageMail.mail.common.send %>
337
+ <div style="color: black;"><span style="font-weight: 500;"><%= companyName %></span> <%= languageMail.mail.common.send %>
338
338
  <% if(idEstimateInvoiceType) { %>
339
339
  <% if ([${EstimateInvoiceType.FACTURE}, ${EstimateInvoiceType.RESTE}].includes(idEstimateInvoiceType)) { %>
340
340
  <%= languageMail.mail.invoice.title %>
@@ -370,7 +370,7 @@ export class CommercialDocumentMailHTML {
370
370
  <% } else { %>
371
371
  <%= languageMail.mail.default.subTitle %>
372
372
  <% } %>
373
- <%- reference %>
373
+ <%= reference %>
374
374
  </div>
375
375
  <div style="; color: <%= brandColors.primary500 %>; font-size: 14px;">
376
376
  <%= languageMail.mail.common.dueDate %> : <%- dueDateFormatted %>
package/ts/utils/email.ts CHANGED
@@ -366,25 +366,30 @@ export function generateSignature(params: any): string {
366
366
  user.calendlyUrl = calendlyUrl;
367
367
  }
368
368
 
369
- return ejs.render(signatureTemplate, {
370
- forceRole,
371
- isFacturation: user.roles ? user.roles.some((r: any) => r.id === Role.FACTURATION) : false,
372
- isDataOfficer: user.roles ? user.roles.some((r: any) => r.id === Role.DATA_OFFICER) : true,
373
- brandName,
374
- brandPostalAddress,
375
- brandCity,
376
- idTypeBrand,
377
- commercialName,
378
- iban,
379
- bic,
380
- hostS3,
381
- user,
382
- roleSignature,
383
- mailSignature,
384
- phoneSignature: commercialName !== 'Comptalib' && phoneSignature ? phoneSignature.replace(/\s+/gm, '&nbsp;') : null,
385
- standardPhoneSignature: commercialName !== 'Comptalib' ? standardPhoneSignature.replace(/\s+/gm, '&nbsp;') : null,
386
- horaires,
387
- primaryColor,
388
- supportMail,
389
- });
369
+ return ejs.render(
370
+ signatureTemplate,
371
+ {
372
+ forceRole,
373
+ isFacturation: user.roles ? user.roles.some((r: any) => r.id === Role.FACTURATION) : false,
374
+ isDataOfficer: user.roles ? user.roles.some((r: any) => r.id === Role.DATA_OFFICER) : true,
375
+ brandName,
376
+ brandPostalAddress,
377
+ brandCity,
378
+ idTypeBrand,
379
+ commercialName,
380
+ iban,
381
+ bic,
382
+ hostS3,
383
+ user,
384
+ roleSignature,
385
+ mailSignature,
386
+ phoneSignature:
387
+ commercialName !== 'Comptalib' && phoneSignature ? phoneSignature.replace(/\s+/gm, '&nbsp;') : null,
388
+ standardPhoneSignature: commercialName !== 'Comptalib' ? standardPhoneSignature.replace(/\s+/gm, '&nbsp;') : null,
389
+ horaires,
390
+ primaryColor,
391
+ supportMail,
392
+ },
393
+ {},
394
+ );
390
395
  }
@@ -7,3 +7,17 @@ export function isHtml(text: string): boolean {
7
7
  const htmlRegex = /<([A-Za-z][A-Za-z0-9]*)\b[^>]*>(.*?)<\/\1>/i;
8
8
  return htmlRegex.test(text);
9
9
  }
10
+
11
+ /**
12
+ * Verify if an HTML string is considered empty (e.g. "", "<p><br></p>", "<p>&nbsp;</p>", or empty tags with no visible text/image)
13
+ * @param html - The HTML string to verify
14
+ * @returns {boolean}
15
+ */
16
+ export function isHtmlEmpty(html?: string | null): boolean {
17
+ if (!html) return true;
18
+ const textWithoutTags = html
19
+ .replace(/<(?!\/?img\b)[^>]*>/gi, '')
20
+ .replace(/&(?:nbsp|#160|#xa0);/gi, '')
21
+ .trim();
22
+ return textWithoutTags.length === 0;
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from 'vitest';
2
2
  import { generateCommercialDocument } from '../commercialManagement/document.js';
3
+ import { isHtmlEmpty } from '../isHtml.js';
3
4
  import { EstimateInvoiceType } from '../../types/Enum/EstimateInvoice.js';
4
5
  import { CreateInvoiceDocument } from '../../types/Interface/api/commercialManagement/Create.js';
5
6
  import { DiscountType } from '../../types/Enum/CommercialManagement.js';
@@ -24,7 +25,7 @@ const payloadWithoutSellerAddress = {
24
25
  reference: '202609/94',
25
26
  capital: 0,
26
27
  isDraft: false,
27
- cgv: '<p><br></p>',
28
+ cgv: '<p>Test avec une liste</p><ol><li data-list="bullet"><span class="ql-ui"></span>1</li><li data-list="bullet"><span class="ql-ui"></span>2</li></ol></p>',
28
29
  logoUrl: null,
29
30
  disableVat: false,
30
31
  nafCode: null,
@@ -94,7 +95,8 @@ describe('generateCommercialDocument', () => {
94
95
  test.each([true, false])(
95
96
  'should render CGV HTML only when invoiceCgv is enabled (%s)',
96
97
  (invoiceCgv: boolean): void => {
97
- const cgv = '<p>Conditions de vente <strong>applicables</strong></p>';
98
+ const cgv =
99
+ '<p>Test avec une liste</p><ol><li data-list="bullet"><span class="ql-ui"></span>1</li><li data-list="bullet"><span class="ql-ui"></span>2</li></ol>';
98
100
  const payload: CreateInvoiceDocument = {
99
101
  ...payloadWithoutSellerAddress,
100
102
  cgv,
@@ -117,6 +119,23 @@ describe('generateCommercialDocument', () => {
117
119
  expect(result.body).not.toContain('<p>undefined</p>');
118
120
  });
119
121
 
122
+ test.each(['<p><br></p>', '<p><br/></p>', '<p></p>', '<p>&nbsp;</p>', ' '])(
123
+ 'should not render CGV or page break when cgv contains empty HTML (%s)',
124
+ (emptyCgv: string): void => {
125
+ const payload: CreateInvoiceDocument = {
126
+ ...payloadWithoutSellerAddress,
127
+ cgv: emptyCgv,
128
+ settings: { invoiceCgv: true },
129
+ };
130
+
131
+ const result = generateCommercialDocument(EstimateInvoiceType.FACTURE, payload);
132
+
133
+ expect(result.data.cgv).toBeNull();
134
+ expect(result.body).not.toContain('page-break-after:always;');
135
+ expect(result.body).not.toContain('id="WYSIWYG"');
136
+ },
137
+ );
138
+
120
139
  test('should calculate VAT from rounded line totals after discounts and fractional quantities', () => {
121
140
  const payload = {
122
141
  ...payloadWithoutSellerAddress,
@@ -209,3 +228,24 @@ describe('generateCommercialDocument', () => {
209
228
  expect(result.data.totalTaxIncluded).toBe(-227);
210
229
  });
211
230
  });
231
+
232
+ describe('isHtmlEmpty', () => {
233
+ test.each([
234
+ [undefined, true],
235
+ [null, true],
236
+ ['', true],
237
+ [' ', true],
238
+ ['<p><br></p>', true],
239
+ ['<p><br/></p>', true],
240
+ ['<p></p>', true],
241
+ ['<p>&nbsp;</p>', true],
242
+ ['<p>&#160;</p>', true],
243
+ ['<p><br></p><p><br></p>', true],
244
+ ['<div><p><br></p></div>', true],
245
+ ['<p>Conditions Générales</p>', false],
246
+ ['<p><img src="test.png"></p>', false],
247
+ ['<img src="test.png" />', false],
248
+ ])('isHtmlEmpty(%j) should return %s', (input, expected) => {
249
+ expect(isHtmlEmpty(input as string)).toBe(expected);
250
+ });
251
+ });
@@ -0,0 +1,35 @@
1
+ import { Transform } from 'class-transformer';
2
+
3
+ const valueToStringArray = (value: any) => {
4
+ if (value === null || value === undefined || value === '') {
5
+ return undefined;
6
+ }
7
+ if (Array.isArray(value)) {
8
+ return value.map((v) => String(v).trim());
9
+ }
10
+ return String(value)
11
+ .split(',')
12
+ .map((v) => v.trim())
13
+ .filter((v) => v !== '');
14
+ };
15
+
16
+ // Accepts repeated query params (`a=x&a=y`) as well as comma-separated values (`a=x,y`).
17
+ const ToStringArray = () => {
18
+ const toPlain = Transform(
19
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
20
+ ({ value }) => value,
21
+ {
22
+ toPlainOnly: true,
23
+ },
24
+ );
25
+ const toClass = (target: any, key: string) =>
26
+ Transform(({ obj }) => valueToStringArray(obj[key]), {
27
+ toClassOnly: true,
28
+ })(target, key);
29
+ return (target: any, key: string) => {
30
+ toPlain(target, key);
31
+ toClass(target, key);
32
+ };
33
+ };
34
+
35
+ export { ToStringArray };