@hed-hog/contact 0.0.312 → 0.0.314

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.
@@ -61,6 +61,14 @@
61
61
  slug: admin
62
62
  - where:
63
63
  slug: admin-contact
64
+ - url: /person/linked-user-options
65
+ method: GET
66
+ relations:
67
+ role:
68
+ - where:
69
+ slug: admin
70
+ - where:
71
+ slug: admin-contact
64
72
  - url: /person/followups
65
73
  method: GET
66
74
  relations: *a3
@@ -18,6 +18,7 @@ import {
18
18
  CollapsibleContent,
19
19
  CollapsibleTrigger,
20
20
  } from '@/components/ui/collapsible';
21
+ import { EntityPicker } from '@/components/ui/entity-picker';
21
22
  import { Input } from '@/components/ui/input';
22
23
  import { Label } from '@/components/ui/label';
23
24
  import {
@@ -59,16 +60,20 @@ import {
59
60
  Calendar as CalendarIcon,
60
61
  ChevronDown,
61
62
  ChevronUp,
63
+ Eye,
64
+ EyeOff,
62
65
  FileText,
63
66
  Loader2,
64
67
  Mail,
65
68
  MapPin,
66
69
  Plus,
70
+ RefreshCw,
67
71
  Save,
68
72
  Star,
69
73
  Trash2,
70
74
  Upload,
71
75
  User,
76
+ UserRound,
72
77
  } from 'lucide-react';
73
78
  import { useTranslations } from 'next-intl';
74
79
  import {
@@ -176,6 +181,7 @@ type PersonSubmitPayload = {
176
181
  job_title: string | null;
177
182
  employer_company_id: number | null;
178
183
  owner_user_id: number | null;
184
+ user_id: number | null;
179
185
  source: PersonSource | null;
180
186
  lifecycle_stage: PersonLifecycleStage | null;
181
187
  next_action_at: string | null;
@@ -230,6 +236,8 @@ type PersonDraftPayload = {
230
236
  documents: EditablePersonDocument[];
231
237
  avatarId: number | null;
232
238
  avatarPreviewUrl: string;
239
+ linkedUserId: number | null;
240
+ linkedUserLabel: string;
233
241
  };
234
242
 
235
243
  const PERSON_FORM_DRAFT_STORAGE_KEY = 'contact-person-form-draft';
@@ -800,6 +808,18 @@ export function PersonFormSheet({
800
808
  const [contacts, setContacts] = useState<EditablePersonContact[]>([]);
801
809
  const [addresses, setAddresses] = useState<EditablePersonAddress[]>([]);
802
810
  const [documents, setDocuments] = useState<EditablePersonDocument[]>([]);
811
+ const [linkedUserId, setLinkedUserId] = useState<number | null>(null);
812
+ const [linkedUserLabel, setLinkedUserLabel] = useState<string>('');
813
+ const [userCreateShowPassword, setUserCreateShowPassword] = useState(false);
814
+ const [usersOpen, setUsersOpen] = useState(true);
815
+
816
+ const generatePassword = () => {
817
+ const chars =
818
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
819
+ const array = new Uint8Array(16);
820
+ crypto.getRandomValues(array);
821
+ return Array.from(array, (byte) => chars[byte % chars.length]).join('');
822
+ };
803
823
  const [contactsOpen, setContactsOpen] = useState(true);
804
824
  const [addressesOpen, setAddressesOpen] = useState(true);
805
825
  const [documentsOpen, setDocumentsOpen] = useState(true);
@@ -862,11 +882,12 @@ export function PersonFormSheet({
862
882
  watchedFormValues.source ||
863
883
  (watchedFormValues.lifecycle_stage ?? 'new') !== 'new' ||
864
884
  avatarId != null ||
885
+ linkedUserId != null ||
865
886
  contacts.length > 0 ||
866
887
  addresses.length > 0 ||
867
888
  documents.length > 0
868
889
  ),
869
- [watchedFormValues, avatarId, contacts, addresses, documents]
890
+ [watchedFormValues, avatarId, linkedUserId, contacts, addresses, documents]
870
891
  );
871
892
 
872
893
  const draftValue = useMemo<PersonDraftPayload>(
@@ -898,6 +919,8 @@ export function PersonFormSheet({
898
919
  documents,
899
920
  avatarId,
900
921
  avatarPreviewUrl,
922
+ linkedUserId,
923
+ linkedUserLabel,
901
924
  }),
902
925
  [
903
926
  watchedFormValues,
@@ -906,6 +929,8 @@ export function PersonFormSheet({
906
929
  documents,
907
930
  avatarId,
908
931
  avatarPreviewUrl,
932
+ linkedUserId,
933
+ linkedUserLabel,
909
934
  person?.id,
910
935
  ]
911
936
  );
@@ -1074,6 +1099,15 @@ export function PersonFormSheet({
1074
1099
  setContactsOpen(true);
1075
1100
  setAddressesOpen(true);
1076
1101
  setDocumentsOpen(true);
1102
+ setUsersOpen(true);
1103
+ setLinkedUserId(
1104
+ restoredDraft?.linkedUserId ?? person?.person_user?.[0]?.user_id ?? null
1105
+ );
1106
+ setLinkedUserLabel(
1107
+ restoredDraft?.linkedUserLabel ??
1108
+ person?.person_user?.[0]?.user?.name ??
1109
+ ''
1110
+ );
1077
1111
  setLoadingCEP({});
1078
1112
  setDuplicateDialogOpen(false);
1079
1113
  setPendingDuplicateSubmission(null);
@@ -1638,6 +1672,7 @@ export function PersonFormSheet({
1638
1672
  ? (values.employer_company_id ?? null)
1639
1673
  : null,
1640
1674
  owner_user_id: values.owner_user_id ?? null,
1675
+ user_id: linkedUserId,
1641
1676
  source: values.source || null,
1642
1677
  lifecycle_stage: values.lifecycle_stage || 'new',
1643
1678
  next_action_at: values.next_action_at
@@ -1847,6 +1882,7 @@ export function PersonFormSheet({
1847
1882
  (item) => item.document_type_id === document.document_type_id
1848
1883
  ) ?? undefined,
1849
1884
  })),
1885
+ person_user: payload.user_id != null ? [{ user_id: payload.user_id }] : [],
1850
1886
  });
1851
1887
 
1852
1888
  const finalizeSuccess = async (personId: number, fallbackPerson?: Person) => {
@@ -2432,6 +2468,235 @@ export function PersonFormSheet({
2432
2468
 
2433
2469
  <Separator />
2434
2470
 
2471
+ <Collapsible open={usersOpen} onOpenChange={setUsersOpen}>
2472
+ <CollapsibleTrigger asChild>
2473
+ <div className="group flex cursor-pointer items-center justify-between">
2474
+ <div className="flex items-center gap-2">
2475
+ <UserRound className="h-4 w-4 text-violet-500" />
2476
+ <h3 className="text-sm font-semibold">
2477
+ {t('linkedUser')}
2478
+ </h3>
2479
+ {linkedUserId != null ? (
2480
+ <Badge
2481
+ variant="secondary"
2482
+ className="bg-violet-500/10 text-violet-600"
2483
+ >
2484
+ 1
2485
+ </Badge>
2486
+ ) : null}
2487
+ </div>
2488
+ <div className="flex items-center gap-1">
2489
+ {usersOpen ? (
2490
+ <ChevronUp className="h-4 w-4" />
2491
+ ) : (
2492
+ <ChevronDown className="h-4 w-4" />
2493
+ )}
2494
+ </div>
2495
+ </div>
2496
+ </CollapsibleTrigger>
2497
+
2498
+ <CollapsibleContent className="mt-2">
2499
+ <EntityPicker
2500
+ value={linkedUserId}
2501
+ onChange={(val, opt) => {
2502
+ setLinkedUserId(val as number | null);
2503
+ setLinkedUserLabel(
2504
+ opt
2505
+ ? ((opt as { name?: string; label?: string }).name ??
2506
+ (opt as { name?: string; label?: string })
2507
+ .label ??
2508
+ '')
2509
+ : ''
2510
+ );
2511
+ }}
2512
+ placeholder={t('selectLinkedUser')}
2513
+ entityLabel={t('user')}
2514
+ clearable
2515
+ valueType="number"
2516
+ initialSelectedLabel={linkedUserLabel}
2517
+ loadOptions={async ({ search }) => {
2518
+ const params = new URLSearchParams();
2519
+ if (search) params.set('search', search);
2520
+ const response = await request<
2521
+ Array<{ id: number; name: string; email?: string }>
2522
+ >({
2523
+ url: `/person/linked-user-options?${params.toString()}`,
2524
+ method: 'GET',
2525
+ });
2526
+ const items = Array.isArray(response.data)
2527
+ ? response.data
2528
+ : [];
2529
+ return { items, hasMore: false };
2530
+ }}
2531
+ getOptionValue={(opt) => (opt as { id: number }).id}
2532
+ getOptionLabel={(opt) =>
2533
+ (opt as { name: string; email?: string }).name
2534
+ }
2535
+ getOptionDescription={(opt) =>
2536
+ (opt as { email?: string }).email
2537
+ }
2538
+ showCreateButton
2539
+ createTitle={t('createUserTitle')}
2540
+ mapSearchToCreateValues={(search) => ({
2541
+ name: search,
2542
+ password: generatePassword(),
2543
+ })}
2544
+ createFields={[
2545
+ {
2546
+ name: 'name',
2547
+ label: t('userName'),
2548
+ placeholder: t('userNamePlaceholder'),
2549
+ required: true,
2550
+ },
2551
+ {
2552
+ name: 'email',
2553
+ label: t('userEmail'),
2554
+ placeholder: t('userEmailPlaceholder'),
2555
+ type: 'email',
2556
+ required: true,
2557
+ },
2558
+ {
2559
+ name: 'password',
2560
+ label: t('userPassword'),
2561
+ placeholder: t('userPasswordPlaceholder'),
2562
+ type: 'password',
2563
+ required: true,
2564
+ },
2565
+ ]}
2566
+ renderCreateContent={(ctx) => (
2567
+ <div className="mt-6 space-y-4">
2568
+ <div className="space-y-2">
2569
+ <Label>
2570
+ {t('userName')}{' '}
2571
+ <span className="text-destructive">*</span>
2572
+ </Label>
2573
+ <Input
2574
+ value={ctx.values.name ?? ''}
2575
+ onChange={(e) =>
2576
+ ctx.setValue('name', e.target.value)
2577
+ }
2578
+ placeholder={t('userNamePlaceholder')}
2579
+ />
2580
+ </div>
2581
+ <div className="space-y-2">
2582
+ <Label>
2583
+ {t('userEmail')}{' '}
2584
+ <span className="text-destructive">*</span>
2585
+ </Label>
2586
+ <Input
2587
+ type="email"
2588
+ value={ctx.values.email ?? ''}
2589
+ onChange={(e) =>
2590
+ ctx.setValue('email', e.target.value)
2591
+ }
2592
+ placeholder={t('userEmailPlaceholder')}
2593
+ />
2594
+ </div>
2595
+ <div className="space-y-2">
2596
+ <Label>
2597
+ {t('userPassword')}{' '}
2598
+ <span className="text-destructive">*</span>
2599
+ </Label>
2600
+ <div className="flex gap-2">
2601
+ <div className="relative flex-1">
2602
+ <Input
2603
+ type={
2604
+ userCreateShowPassword ? 'text' : 'password'
2605
+ }
2606
+ value={ctx.values.password ?? ''}
2607
+ onChange={(e) =>
2608
+ ctx.setValue('password', e.target.value)
2609
+ }
2610
+ placeholder={t('userPasswordPlaceholder')}
2611
+ className="pr-10"
2612
+ />
2613
+ <Button
2614
+ type="button"
2615
+ variant="ghost"
2616
+ size="icon"
2617
+ className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
2618
+ onClick={() =>
2619
+ setUserCreateShowPassword((v) => !v)
2620
+ }
2621
+ tabIndex={-1}
2622
+ >
2623
+ {userCreateShowPassword ? (
2624
+ <EyeOff className="h-4 w-4" />
2625
+ ) : (
2626
+ <Eye className="h-4 w-4" />
2627
+ )}
2628
+ </Button>
2629
+ </div>
2630
+ <Button
2631
+ type="button"
2632
+ variant="outline"
2633
+ size="icon"
2634
+ className="h-10 w-10 shrink-0"
2635
+ title={t('generatePassword')}
2636
+ onClick={() =>
2637
+ ctx.setValue('password', generatePassword())
2638
+ }
2639
+ >
2640
+ <RefreshCw className="h-4 w-4" />
2641
+ </Button>
2642
+ </div>
2643
+ </div>
2644
+ <div className="flex justify-end gap-2">
2645
+ <Button
2646
+ type="button"
2647
+ variant="outline"
2648
+ onClick={ctx.closeCreate}
2649
+ >
2650
+ {t('cancel')}
2651
+ </Button>
2652
+ <Button
2653
+ type="button"
2654
+ disabled={
2655
+ ctx.isCreating ||
2656
+ !ctx.values.name?.trim() ||
2657
+ !ctx.values.email?.trim() ||
2658
+ !ctx.values.password?.trim()
2659
+ }
2660
+ onClick={() => void ctx.submitCreate()}
2661
+ >
2662
+ {ctx.isCreating ? (
2663
+ <Loader2 className="h-4 w-4 animate-spin" />
2664
+ ) : null}
2665
+ {t('save')}
2666
+ </Button>
2667
+ </div>
2668
+ </div>
2669
+ )}
2670
+ searchPlaceholder={t('searchUserPlaceholder')}
2671
+ emptyStateDescription={t('noUsersFound')}
2672
+ createActionLabel={t('createUser')}
2673
+ onCreate={async (values) => {
2674
+ const v = values as Record<string, string>;
2675
+ const response = await request<{
2676
+ user: { id: number; name: string };
2677
+ }>({
2678
+ url: '/user',
2679
+ method: 'POST',
2680
+ data: {
2681
+ name: v.name,
2682
+ email: v.email,
2683
+ password: v.password,
2684
+ },
2685
+ });
2686
+ const user = response.data?.user;
2687
+ if (!user?.id) return null;
2688
+ return {
2689
+ id: user.id,
2690
+ name: user.name,
2691
+ email: v.email,
2692
+ };
2693
+ }}
2694
+ />
2695
+ </CollapsibleContent>
2696
+ </Collapsible>
2697
+
2698
+ <Separator />
2699
+
2435
2700
  <Collapsible open={contactsOpen} onOpenChange={setContactsOpen}>
2436
2701
  <CollapsibleTrigger asChild>
2437
2702
  <div className="group flex cursor-pointer items-center justify-between">
@@ -91,6 +91,7 @@ export type PersonInteractionType =
91
91
  export type UserOption = {
92
92
  id: number;
93
93
  name: string;
94
+ email?: string;
94
95
  photo_id?: number | null;
95
96
  };
96
97
 
@@ -140,6 +141,10 @@ export type Person = {
140
141
  contact?: PersonContact[];
141
142
  address?: PersonAddress[];
142
143
  document?: PersonDocument[];
144
+ person_user?: Array<{
145
+ user_id: number;
146
+ user?: { id: number; name: string; email?: string };
147
+ }>;
143
148
  };
144
149
 
145
150
  export type PersonInteraction = {
@@ -47,6 +47,20 @@
47
47
  "nameRequired": "Name must be at least 2 characters",
48
48
  "tabGeneral": "General",
49
49
  "tabContacts": "Contacts",
50
+ "linkedUser": "Linked User",
51
+ "selectLinkedUser": "Select a user",
52
+ "user": "User",
53
+ "createUserTitle": "Create User",
54
+ "createUser": "Create User",
55
+ "userName": "Name",
56
+ "userNamePlaceholder": "Full name",
57
+ "userEmail": "Email",
58
+ "userEmailPlaceholder": "email@example.com",
59
+ "userPassword": "Password",
60
+ "userPasswordPlaceholder": "Strong password",
61
+ "generatePassword": "Generate password",
62
+ "searchUserPlaceholder": "Search user...",
63
+ "noUsersFound": "No users found",
50
64
  "tabAddresses": "Addresses",
51
65
  "tabDocuments": "Documents",
52
66
  "contactType": "Contact Type",
@@ -46,6 +46,20 @@
46
46
  "nameRequired": "O nome deve ter pelo menos 2 caracteres",
47
47
  "tabGeneral": "Geral",
48
48
  "tabContacts": "Contatos",
49
+ "linkedUser": "Usuário Vinculado",
50
+ "selectLinkedUser": "Selecionar um usuário",
51
+ "user": "Usuário",
52
+ "createUserTitle": "Criar Usuário",
53
+ "createUser": "Criar Usuário",
54
+ "userName": "Nome",
55
+ "userNamePlaceholder": "Nome completo",
56
+ "userEmail": "Email",
57
+ "userEmailPlaceholder": "email@exemplo.com",
58
+ "userPassword": "Senha",
59
+ "userPasswordPlaceholder": "Senha forte",
60
+ "generatePassword": "Gerar senha",
61
+ "searchUserPlaceholder": "Buscar usuário...",
62
+ "noUsersFound": "Nenhum usuário encontrado",
49
63
  "tabAddresses": "Endereços",
50
64
  "tabDocuments": "Documentos",
51
65
  "contactType": "Tipo de Contato",
@@ -0,0 +1,18 @@
1
+ columns:
2
+ - type: pk
3
+ - name: person_id
4
+ type: fk
5
+ references:
6
+ table: person
7
+ column: id
8
+ onDelete: CASCADE
9
+ onUpdate: CASCADE
10
+ - name: user_id
11
+ type: fk
12
+ references:
13
+ table: user
14
+ column: id
15
+ onDelete: CASCADE
16
+ onUpdate: CASCADE
17
+ - type: created_at
18
+ - type: updated_at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/contact",
3
- "version": "0.0.312",
3
+ "version": "0.0.314",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -9,13 +9,13 @@
9
9
  "@nestjs/core": "^11",
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
+ "@hed-hog/core": "0.0.314",
13
+ "@hed-hog/address": "0.0.314",
12
14
  "@hed-hog/api-mail": "0.0.9",
13
15
  "@hed-hog/api": "0.0.6",
14
- "@hed-hog/core": "0.0.312",
15
- "@hed-hog/api-locale": "0.0.14",
16
- "@hed-hog/api-pagination": "0.0.7",
17
16
  "@hed-hog/api-prisma": "0.0.6",
18
- "@hed-hog/address": "0.0.312"
17
+ "@hed-hog/api-locale": "0.0.14",
18
+ "@hed-hog/api-pagination": "0.0.7"
19
19
  },
20
20
  "exports": {
21
21
  ".": {
@@ -1,12 +1,12 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import {
3
- IsArray,
4
- IsDateString,
5
- IsEnum,
6
- IsInt,
7
- IsOptional,
8
- IsNumber,
9
- IsString,
3
+ IsArray,
4
+ IsDateString,
5
+ IsEnum,
6
+ IsInt,
7
+ IsNumber,
8
+ IsOptional,
9
+ IsString,
10
10
  } from 'class-validator';
11
11
 
12
12
  export enum PersonType {
@@ -94,6 +94,10 @@ export class CreateDTO {
94
94
  @IsInt({ message: (args) => getLocaleText('validation.idMustBeInteger', args.value) })
95
95
  owner_user_id?: number | null;
96
96
 
97
+ @IsOptional()
98
+ @IsInt({ message: (args) => getLocaleText('validation.idMustBeInteger', args.value) })
99
+ user_id?: number | null;
100
+
97
101
  @IsOptional()
98
102
  @IsEnum(PersonSource, { message: (args) => getLocaleText('validation.typeMustBeEnum', args.value) })
99
103
  source?: PersonSource | null;
@@ -1,18 +1,18 @@
1
- import { AddressTypeEnum } from '../../address-type.enum';
2
- import { PersonGender, PersonLifecycleStage, PersonSource } from './create.dto';
3
1
  import { getLocaleText } from '@hed-hog/api-locale';
4
2
  import { Type } from 'class-transformer';
5
3
  import {
6
- IsArray,
7
- IsBoolean,
8
- IsDateString,
9
- IsEnum,
10
- IsInt,
11
- IsNumber,
12
- IsOptional,
13
- IsString,
14
- ValidateNested,
4
+ IsArray,
5
+ IsBoolean,
6
+ IsDateString,
7
+ IsEnum,
8
+ IsInt,
9
+ IsNumber,
10
+ IsOptional,
11
+ IsString,
12
+ ValidateNested,
15
13
  } from 'class-validator';
14
+ import { AddressTypeEnum } from '../../address-type.enum';
15
+ import { PersonGender, PersonLifecycleStage, PersonSource } from './create.dto';
16
16
 
17
17
  export class UpdateAllContactDTO {
18
18
  @IsOptional()
@@ -126,6 +126,10 @@ export class UpdateAllPersonDTO {
126
126
  @IsInt({ message: (args) => getLocaleText('validation.idMustBeInteger', args.value) })
127
127
  owner_user_id?: number | null;
128
128
 
129
+ @IsOptional()
130
+ @IsInt({ message: (args) => getLocaleText('validation.idMustBeInteger', args.value) })
131
+ user_id?: number | null;
132
+
129
133
  @IsOptional()
130
134
  @IsEnum(PersonSource, { message: (args) => getLocaleText('validation.typeMustBeEnum', args.value) })
131
135
  source?: PersonSource | null;
@@ -2,28 +2,28 @@ import { DeleteDTO, Public, Role, User } from '@hed-hog/api';
2
2
  import { Locale } from '@hed-hog/api-locale';
3
3
  import { Pagination } from '@hed-hog/api-pagination';
4
4
  import {
5
- BadRequestException,
6
- Body,
7
- Controller,
8
- Delete,
9
- Get,
10
- Inject,
11
- Param,
12
- ParseIntPipe,
13
- Patch,
14
- Post,
15
- Query,
16
- Res,
17
- UploadedFile,
18
- UseInterceptors,
19
- forwardRef
5
+ BadRequestException,
6
+ Body,
7
+ Controller,
8
+ Delete,
9
+ Get,
10
+ Inject,
11
+ Param,
12
+ ParseIntPipe,
13
+ Patch,
14
+ Post,
15
+ Query,
16
+ Res,
17
+ UploadedFile,
18
+ UseInterceptors,
19
+ forwardRef
20
20
  } from '@nestjs/common';
21
21
  import { FileInterceptor } from '@nestjs/platform-express';
22
22
  import { Response } from 'express';
23
23
  import {
24
- AccountListQueryDTO,
25
- CreateAccountDTO,
26
- UpdateAccountDTO,
24
+ AccountListQueryDTO,
25
+ CreateAccountDTO,
26
+ UpdateAccountDTO,
27
27
  } from './dto/account.dto';
28
28
  import { ActivityListQueryDTO } from './dto/activity.dto';
29
29
  import { CreateFollowupDTO } from './dto/create-followup.dto';
@@ -32,8 +32,8 @@ import { CreateDTO } from './dto/create.dto';
32
32
  import { DashboardQueryDTO } from './dto/dashboard-query.dto';
33
33
  import { CheckPersonDuplicatesQueryDTO } from './dto/duplicates-query.dto';
34
34
  import {
35
- FollowupListQueryDTO,
36
- FollowupStatsQueryDTO,
35
+ FollowupListQueryDTO,
36
+ FollowupStatsQueryDTO,
37
37
  } from './dto/followup-query.dto';
38
38
  import { MergePersonDTO } from './dto/merge.dto';
39
39
  import { ReportsQueryDTO } from './dto/reports-query.dto';
@@ -89,8 +89,13 @@ export class PersonController {
89
89
  }
90
90
 
91
91
  @Get('owner-options')
92
- async getOwnerOptions(@User() user) {
93
- return this.personService.getOwnerOptions(Number(user?.id || 0));
92
+ async getOwnerOptions(@User() user, @Query('search') search?: string) {
93
+ return this.personService.getOwnerOptions(Number(user?.id || 0), search);
94
+ }
95
+
96
+ @Get('linked-user-options')
97
+ async getLinkedUserOptions(@Query('search') search?: string) {
98
+ return this.personService.getLinkedUserOptions(search);
94
99
  }
95
100
 
96
101
  @Get('duplicates')