@blackcode_sa/metaestetics-api 1.12.62 → 1.12.64
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.
- package/dist/admin/index.d.mts +4 -2
- package/dist/admin/index.d.ts +4 -2
- package/dist/admin/index.js +4 -45
- package/dist/admin/index.mjs +4 -45
- package/dist/backoffice/index.d.mts +86 -1
- package/dist/backoffice/index.d.ts +86 -1
- package/dist/backoffice/index.js +308 -0
- package/dist/backoffice/index.mjs +306 -0
- package/dist/index.d.mts +99 -3
- package/dist/index.d.ts +99 -3
- package/dist/index.js +545 -281
- package/dist/index.mjs +867 -603
- package/package.json +119 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1844 -1844
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +641 -689
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +75 -75
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +40 -40
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +318 -318
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +11 -8
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +395 -395
- package/src/backoffice/services/technology.service.ts +1083 -1070
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +62 -62
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +163 -161
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -163
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2505 -2082
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +13 -13
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +1682 -1682
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +636 -683
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/appointment/index.ts +481 -453
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +489 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +44 -44
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +265 -265
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -273
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +130 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +493 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -216
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +189 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,553 +1,553 @@
|
|
|
1
|
-
import {
|
|
2
|
-
addDoc,
|
|
3
|
-
collection,
|
|
4
|
-
collectionGroup,
|
|
5
|
-
doc,
|
|
6
|
-
getDoc,
|
|
7
|
-
getDocs,
|
|
8
|
-
query,
|
|
9
|
-
updateDoc,
|
|
10
|
-
where,
|
|
11
|
-
limit,
|
|
12
|
-
orderBy,
|
|
13
|
-
startAfter,
|
|
14
|
-
getCountFromServer,
|
|
15
|
-
QueryConstraint,
|
|
16
|
-
arrayUnion,
|
|
17
|
-
arrayRemove,
|
|
18
|
-
} from 'firebase/firestore';
|
|
19
|
-
import { Product, PRODUCTS_COLLECTION, IProductService } from '../types/product.types';
|
|
20
|
-
import { BaseService } from '../../services/base.service';
|
|
21
|
-
import { TECHNOLOGIES_COLLECTION } from '../types/technology.types';
|
|
22
|
-
|
|
23
|
-
export class ProductService extends BaseService implements IProductService {
|
|
24
|
-
/**
|
|
25
|
-
* Gets reference to top-level products collection (source of truth)
|
|
26
|
-
* @returns Firestore collection reference
|
|
27
|
-
*/
|
|
28
|
-
private getTopLevelProductsRef() {
|
|
29
|
-
return collection(this.db, PRODUCTS_COLLECTION);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Gets reference to products collection under a technology (backward compatibility)
|
|
34
|
-
* @param technologyId - ID of the technology
|
|
35
|
-
* @returns Firestore collection reference
|
|
36
|
-
*/
|
|
37
|
-
private getProductsRef(technologyId: string) {
|
|
38
|
-
return collection(this.db, TECHNOLOGIES_COLLECTION, technologyId, PRODUCTS_COLLECTION);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Creates a new product under technology
|
|
43
|
-
*/
|
|
44
|
-
async create(
|
|
45
|
-
technologyId: string,
|
|
46
|
-
brandId: string,
|
|
47
|
-
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'technologyId'>,
|
|
48
|
-
): Promise<Product> {
|
|
49
|
-
const now = new Date();
|
|
50
|
-
// Create product with legacy structure for subcollection compatibility
|
|
51
|
-
const newProduct: Omit<Product, 'id'> = {
|
|
52
|
-
...product,
|
|
53
|
-
brandId,
|
|
54
|
-
technologyId, // Required for old subcollection structure
|
|
55
|
-
createdAt: now,
|
|
56
|
-
updatedAt: now,
|
|
57
|
-
isActive: true,
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const productRef = await addDoc(this.getProductsRef(technologyId), newProduct);
|
|
61
|
-
|
|
62
|
-
return { id: productRef.id, ...newProduct };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Gets a paginated list of all products, with optional filters.
|
|
67
|
-
* This uses a collectionGroup query to search across all technologies.
|
|
68
|
-
*/
|
|
69
|
-
async getAll(options: {
|
|
70
|
-
rowsPerPage: number;
|
|
71
|
-
lastVisible?: any;
|
|
72
|
-
categoryId?: string;
|
|
73
|
-
subcategoryId?: string;
|
|
74
|
-
technologyId?: string;
|
|
75
|
-
}): Promise<{ products: Product[]; lastVisible: any }> {
|
|
76
|
-
const { rowsPerPage, lastVisible, categoryId, subcategoryId, technologyId } = options;
|
|
77
|
-
|
|
78
|
-
const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
|
|
79
|
-
|
|
80
|
-
if (categoryId) {
|
|
81
|
-
constraints.push(where('categoryId', '==', categoryId));
|
|
82
|
-
}
|
|
83
|
-
if (subcategoryId) {
|
|
84
|
-
constraints.push(where('subcategoryId', '==', subcategoryId));
|
|
85
|
-
}
|
|
86
|
-
if (technologyId) {
|
|
87
|
-
constraints.push(where('technologyId', '==', technologyId));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (lastVisible) {
|
|
91
|
-
constraints.push(startAfter(lastVisible));
|
|
92
|
-
}
|
|
93
|
-
constraints.push(limit(rowsPerPage));
|
|
94
|
-
|
|
95
|
-
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), ...constraints);
|
|
96
|
-
const snapshot = await getDocs(q);
|
|
97
|
-
|
|
98
|
-
const products = snapshot.docs.map(
|
|
99
|
-
doc =>
|
|
100
|
-
({
|
|
101
|
-
id: doc.id,
|
|
102
|
-
...doc.data(),
|
|
103
|
-
} as Product),
|
|
104
|
-
);
|
|
105
|
-
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
106
|
-
|
|
107
|
-
return { products, lastVisible: newLastVisible };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Gets the total count of active products, with optional filters.
|
|
112
|
-
*/
|
|
113
|
-
async getProductsCount(options: {
|
|
114
|
-
categoryId?: string;
|
|
115
|
-
subcategoryId?: string;
|
|
116
|
-
technologyId?: string;
|
|
117
|
-
}): Promise<number> {
|
|
118
|
-
const { categoryId, subcategoryId, technologyId } = options;
|
|
119
|
-
const constraints: QueryConstraint[] = [where('isActive', '==', true)];
|
|
120
|
-
|
|
121
|
-
if (categoryId) {
|
|
122
|
-
constraints.push(where('categoryId', '==', categoryId));
|
|
123
|
-
}
|
|
124
|
-
if (subcategoryId) {
|
|
125
|
-
constraints.push(where('subcategoryId', '==', subcategoryId));
|
|
126
|
-
}
|
|
127
|
-
if (technologyId) {
|
|
128
|
-
constraints.push(where('technologyId', '==', technologyId));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), ...constraints);
|
|
132
|
-
const snapshot = await getCountFromServer(q);
|
|
133
|
-
return snapshot.data().count;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Gets counts of active products grouped by category, subcategory, and technology.
|
|
138
|
-
* Queries technology subcollections which have the legacy fields synced by Cloud Functions.
|
|
139
|
-
*/
|
|
140
|
-
async getProductCounts(): Promise<{
|
|
141
|
-
byCategory: Record<string, number>;
|
|
142
|
-
bySubcategory: Record<string, number>;
|
|
143
|
-
byTechnology: Record<string, number>;
|
|
144
|
-
}> {
|
|
145
|
-
const counts = {
|
|
146
|
-
byCategory: {} as Record<string, number>,
|
|
147
|
-
bySubcategory: {} as Record<string, number>,
|
|
148
|
-
byTechnology: {} as Record<string, number>,
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// Query technology subcollections (which have synced legacy fields)
|
|
152
|
-
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
|
|
153
|
-
const snapshot = await getDocs(q);
|
|
154
|
-
|
|
155
|
-
snapshot.docs.forEach(doc => {
|
|
156
|
-
const product = doc.data() as Product;
|
|
157
|
-
|
|
158
|
-
// Use legacy fields from subcollections
|
|
159
|
-
if (product.categoryId) {
|
|
160
|
-
counts.byCategory[product.categoryId] = (counts.byCategory[product.categoryId] || 0) + 1;
|
|
161
|
-
}
|
|
162
|
-
if (product.subcategoryId) {
|
|
163
|
-
counts.bySubcategory[product.subcategoryId] = (counts.bySubcategory[product.subcategoryId] || 0) + 1;
|
|
164
|
-
}
|
|
165
|
-
if (product.technologyId) {
|
|
166
|
-
counts.byTechnology[product.technologyId] = (counts.byTechnology[product.technologyId] || 0) + 1;
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
return counts;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Gets all products for a specific technology (non-paginated, for filters/dropdowns)
|
|
175
|
-
*/
|
|
176
|
-
async getAllByTechnology(technologyId: string): Promise<Product[]> {
|
|
177
|
-
const q = query(
|
|
178
|
-
this.getProductsRef(technologyId),
|
|
179
|
-
where('isActive', '==', true),
|
|
180
|
-
orderBy('name'),
|
|
181
|
-
);
|
|
182
|
-
const snapshot = await getDocs(q);
|
|
183
|
-
return snapshot.docs.map(
|
|
184
|
-
doc =>
|
|
185
|
-
({
|
|
186
|
-
id: doc.id,
|
|
187
|
-
...doc.data(),
|
|
188
|
-
} as Product),
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Gets all products for a brand by filtering through all technologies
|
|
194
|
-
*/
|
|
195
|
-
async getAllByBrand(brandId: string): Promise<Product[]> {
|
|
196
|
-
const allTechnologiesRef = collection(this.db, TECHNOLOGIES_COLLECTION);
|
|
197
|
-
const technologiesSnapshot = await getDocs(allTechnologiesRef);
|
|
198
|
-
|
|
199
|
-
const products: Product[] = [];
|
|
200
|
-
|
|
201
|
-
for (const techDoc of technologiesSnapshot.docs) {
|
|
202
|
-
const q = query(
|
|
203
|
-
this.getProductsRef(techDoc.id),
|
|
204
|
-
where('brandId', '==', brandId),
|
|
205
|
-
where('isActive', '==', true),
|
|
206
|
-
);
|
|
207
|
-
const snapshot = await getDocs(q);
|
|
208
|
-
products.push(
|
|
209
|
-
...snapshot.docs.map(
|
|
210
|
-
doc =>
|
|
211
|
-
({
|
|
212
|
-
id: doc.id,
|
|
213
|
-
...doc.data(),
|
|
214
|
-
} as Product),
|
|
215
|
-
),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return products;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Updates a product
|
|
224
|
-
*/
|
|
225
|
-
async update(
|
|
226
|
-
technologyId: string,
|
|
227
|
-
productId: string,
|
|
228
|
-
product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId' | 'technologyId'>>,
|
|
229
|
-
): Promise<Product | null> {
|
|
230
|
-
const updateData = {
|
|
231
|
-
...product,
|
|
232
|
-
updatedAt: new Date(),
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const docRef = doc(this.getProductsRef(technologyId), productId);
|
|
236
|
-
await updateDoc(docRef, updateData);
|
|
237
|
-
|
|
238
|
-
return this.getById(technologyId, productId);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Soft deletes a product
|
|
243
|
-
*/
|
|
244
|
-
async delete(technologyId: string, productId: string): Promise<void> {
|
|
245
|
-
await this.update(technologyId, productId, {
|
|
246
|
-
isActive: false,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Gets a product by ID
|
|
252
|
-
*/
|
|
253
|
-
async getById(technologyId: string, productId: string): Promise<Product | null> {
|
|
254
|
-
const docRef = doc(this.getProductsRef(technologyId), productId);
|
|
255
|
-
const docSnap = await getDoc(docRef);
|
|
256
|
-
if (!docSnap.exists()) return null;
|
|
257
|
-
return {
|
|
258
|
-
id: docSnap.id,
|
|
259
|
-
...docSnap.data(),
|
|
260
|
-
} as Product;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ==========================================
|
|
264
|
-
// NEW METHODS: Top-level collection (preferred)
|
|
265
|
-
// ==========================================
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Creates a new product in the top-level collection
|
|
269
|
-
*/
|
|
270
|
-
async createTopLevel(
|
|
271
|
-
brandId: string,
|
|
272
|
-
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
|
|
273
|
-
technologyIds: string[] = [],
|
|
274
|
-
): Promise<Product> {
|
|
275
|
-
const now = new Date();
|
|
276
|
-
const newProduct: Omit<Product, 'id'> = {
|
|
277
|
-
...product,
|
|
278
|
-
brandId,
|
|
279
|
-
assignedTechnologyIds: technologyIds,
|
|
280
|
-
createdAt: now,
|
|
281
|
-
updatedAt: now,
|
|
282
|
-
isActive: true,
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const productRef = await addDoc(this.getTopLevelProductsRef(), newProduct);
|
|
286
|
-
return { id: productRef.id, ...newProduct };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Gets all products from the top-level collection
|
|
291
|
-
*/
|
|
292
|
-
async getAllTopLevel(options: {
|
|
293
|
-
rowsPerPage: number;
|
|
294
|
-
lastVisible?: any;
|
|
295
|
-
brandId?: string;
|
|
296
|
-
}): Promise<{ products: Product[]; lastVisible: any }> {
|
|
297
|
-
const { rowsPerPage, lastVisible, brandId } = options;
|
|
298
|
-
|
|
299
|
-
const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
|
|
300
|
-
|
|
301
|
-
if (brandId) {
|
|
302
|
-
constraints.push(where('brandId', '==', brandId));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (lastVisible) {
|
|
306
|
-
constraints.push(startAfter(lastVisible));
|
|
307
|
-
}
|
|
308
|
-
constraints.push(limit(rowsPerPage));
|
|
309
|
-
|
|
310
|
-
const q = query(this.getTopLevelProductsRef(), ...constraints);
|
|
311
|
-
const snapshot = await getDocs(q);
|
|
312
|
-
|
|
313
|
-
const products = snapshot.docs.map(
|
|
314
|
-
doc =>
|
|
315
|
-
({
|
|
316
|
-
id: doc.id,
|
|
317
|
-
...doc.data(),
|
|
318
|
-
} as Product),
|
|
319
|
-
);
|
|
320
|
-
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
321
|
-
|
|
322
|
-
return { products, lastVisible: newLastVisible };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Gets a product by ID from the top-level collection
|
|
327
|
-
*/
|
|
328
|
-
async getByIdTopLevel(productId: string): Promise<Product | null> {
|
|
329
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
330
|
-
const docSnap = await getDoc(docRef);
|
|
331
|
-
if (!docSnap.exists()) return null;
|
|
332
|
-
return {
|
|
333
|
-
id: docSnap.id,
|
|
334
|
-
...docSnap.data(),
|
|
335
|
-
} as Product;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Updates a product in the top-level collection
|
|
340
|
-
*/
|
|
341
|
-
async updateTopLevel(
|
|
342
|
-
productId: string,
|
|
343
|
-
product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
|
|
344
|
-
): Promise<Product | null> {
|
|
345
|
-
const updateData = {
|
|
346
|
-
...product,
|
|
347
|
-
updatedAt: new Date(),
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
351
|
-
await updateDoc(docRef, updateData);
|
|
352
|
-
|
|
353
|
-
return this.getByIdTopLevel(productId);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Deletes a product from the top-level collection (soft delete)
|
|
358
|
-
*/
|
|
359
|
-
async deleteTopLevel(productId: string): Promise<void> {
|
|
360
|
-
await this.updateTopLevel(productId, {
|
|
361
|
-
isActive: false,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Assigns a product to a technology
|
|
367
|
-
*/
|
|
368
|
-
async assignToTechnology(productId: string, technologyId: string): Promise<void> {
|
|
369
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
370
|
-
await updateDoc(docRef, {
|
|
371
|
-
assignedTechnologyIds: arrayUnion(technologyId),
|
|
372
|
-
updatedAt: new Date(),
|
|
373
|
-
});
|
|
374
|
-
// Cloud Function will handle syncing to subcollection
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Unassigns a product from a technology
|
|
379
|
-
*/
|
|
380
|
-
async unassignFromTechnology(productId: string, technologyId: string): Promise<void> {
|
|
381
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
382
|
-
await updateDoc(docRef, {
|
|
383
|
-
assignedTechnologyIds: arrayRemove(technologyId),
|
|
384
|
-
updatedAt: new Date(),
|
|
385
|
-
});
|
|
386
|
-
// Cloud Function will handle removing from subcollection
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Gets products assigned to a specific technology
|
|
391
|
-
*/
|
|
392
|
-
async getAssignedProducts(technologyId: string): Promise<Product[]> {
|
|
393
|
-
const q = query(
|
|
394
|
-
this.getTopLevelProductsRef(),
|
|
395
|
-
where('assignedTechnologyIds', 'array-contains', technologyId),
|
|
396
|
-
where('isActive', '==', true),
|
|
397
|
-
orderBy('name'),
|
|
398
|
-
);
|
|
399
|
-
const snapshot = await getDocs(q);
|
|
400
|
-
return snapshot.docs.map(
|
|
401
|
-
doc =>
|
|
402
|
-
({
|
|
403
|
-
id: doc.id,
|
|
404
|
-
...doc.data(),
|
|
405
|
-
} as Product),
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Gets products NOT assigned to a specific technology
|
|
411
|
-
*/
|
|
412
|
-
async getUnassignedProducts(technologyId: string): Promise<Product[]> {
|
|
413
|
-
const q = query(
|
|
414
|
-
this.getTopLevelProductsRef(),
|
|
415
|
-
where('isActive', '==', true),
|
|
416
|
-
orderBy('name'),
|
|
417
|
-
);
|
|
418
|
-
const snapshot = await getDocs(q);
|
|
419
|
-
|
|
420
|
-
const allProducts = snapshot.docs.map(
|
|
421
|
-
doc =>
|
|
422
|
-
({
|
|
423
|
-
id: doc.id,
|
|
424
|
-
...doc.data(),
|
|
425
|
-
} as Product),
|
|
426
|
-
);
|
|
427
|
-
|
|
428
|
-
// Filter out products already assigned to this technology
|
|
429
|
-
return allProducts.filter(product =>
|
|
430
|
-
!product.assignedTechnologyIds?.includes(technologyId)
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Gets all products for a brand (from top-level collection)
|
|
436
|
-
*/
|
|
437
|
-
async getByBrand(brandId: string): Promise<Product[]> {
|
|
438
|
-
const q = query(
|
|
439
|
-
this.getTopLevelProductsRef(),
|
|
440
|
-
where('brandId', '==', brandId),
|
|
441
|
-
where('isActive', '==', true),
|
|
442
|
-
orderBy('name'),
|
|
443
|
-
);
|
|
444
|
-
const snapshot = await getDocs(q);
|
|
445
|
-
return snapshot.docs.map(
|
|
446
|
-
doc =>
|
|
447
|
-
({
|
|
448
|
-
id: doc.id,
|
|
449
|
-
...doc.data(),
|
|
450
|
-
} as Product),
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Exports products to CSV string, suitable for Excel/Sheets.
|
|
456
|
-
* Includes headers and optional UTF-8 BOM.
|
|
457
|
-
* By default exports only active products (set includeInactive to true to export all).
|
|
458
|
-
*/
|
|
459
|
-
async exportToCsv(options?: {
|
|
460
|
-
includeInactive?: boolean;
|
|
461
|
-
includeBom?: boolean;
|
|
462
|
-
}): Promise<string> {
|
|
463
|
-
const includeInactive = options?.includeInactive ?? false;
|
|
464
|
-
const includeBom = options?.includeBom ?? true;
|
|
465
|
-
|
|
466
|
-
const headers = [
|
|
467
|
-
"id",
|
|
468
|
-
"name",
|
|
469
|
-
"brandId",
|
|
470
|
-
"brandName",
|
|
471
|
-
"assignedTechnologyIds",
|
|
472
|
-
"description",
|
|
473
|
-
"technicalDetails",
|
|
474
|
-
"dosage",
|
|
475
|
-
"composition",
|
|
476
|
-
"indications",
|
|
477
|
-
"contraindications",
|
|
478
|
-
"warnings",
|
|
479
|
-
"isActive",
|
|
480
|
-
];
|
|
481
|
-
|
|
482
|
-
const rows: string[] = [];
|
|
483
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
484
|
-
|
|
485
|
-
const PAGE_SIZE = 1000;
|
|
486
|
-
let cursor: any | undefined;
|
|
487
|
-
|
|
488
|
-
// Build base constraints
|
|
489
|
-
const constraints: QueryConstraint[] = [];
|
|
490
|
-
if (!includeInactive) {
|
|
491
|
-
constraints.push(where("isActive", "==", true));
|
|
492
|
-
}
|
|
493
|
-
constraints.push(orderBy("name"));
|
|
494
|
-
|
|
495
|
-
// Page through all results from top-level collection
|
|
496
|
-
// eslint-disable-next-line no-constant-condition
|
|
497
|
-
while (true) {
|
|
498
|
-
const queryConstraints: QueryConstraint[] = [...constraints, limit(PAGE_SIZE)];
|
|
499
|
-
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
500
|
-
|
|
501
|
-
const q = query(this.getTopLevelProductsRef(), ...queryConstraints);
|
|
502
|
-
const snapshot = await getDocs(q);
|
|
503
|
-
if (snapshot.empty) break;
|
|
504
|
-
|
|
505
|
-
for (const d of snapshot.docs) {
|
|
506
|
-
const product = ({ id: d.id, ...d.data() } as unknown) as Product;
|
|
507
|
-
rows.push(this.productToCsvRow(product));
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
511
|
-
if (snapshot.size < PAGE_SIZE) break;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const csvBody = rows.join("\r\n");
|
|
515
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
private productToCsvRow(product: Product): string {
|
|
519
|
-
const values = [
|
|
520
|
-
product.id ?? "",
|
|
521
|
-
product.name ?? "",
|
|
522
|
-
product.brandId ?? "",
|
|
523
|
-
product.brandName ?? "",
|
|
524
|
-
product.assignedTechnologyIds?.join(";") ?? "",
|
|
525
|
-
product.description ?? "",
|
|
526
|
-
product.technicalDetails ?? "",
|
|
527
|
-
product.dosage ?? "",
|
|
528
|
-
product.composition ?? "",
|
|
529
|
-
product.indications?.join(";") ?? "",
|
|
530
|
-
product.contraindications?.map(c => c.name).join(";") ?? "",
|
|
531
|
-
product.warnings?.join(";") ?? "",
|
|
532
|
-
String(product.isActive ?? ""),
|
|
533
|
-
];
|
|
534
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private formatDateIso(value: any): string {
|
|
538
|
-
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
539
|
-
if (value instanceof Date) return value.toISOString();
|
|
540
|
-
if (value && typeof value.toDate === "function") {
|
|
541
|
-
const d = value.toDate();
|
|
542
|
-
return d instanceof Date ? d.toISOString() : String(value);
|
|
543
|
-
}
|
|
544
|
-
return String(value ?? "");
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
private formatCsvValue(value: any): string {
|
|
548
|
-
const str = value === null || value === undefined ? "" : String(value);
|
|
549
|
-
// Escape double quotes by doubling them and wrap in quotes
|
|
550
|
-
const escaped = str.replace(/"/g, '""');
|
|
551
|
-
return `"${escaped}"`;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
addDoc,
|
|
3
|
+
collection,
|
|
4
|
+
collectionGroup,
|
|
5
|
+
doc,
|
|
6
|
+
getDoc,
|
|
7
|
+
getDocs,
|
|
8
|
+
query,
|
|
9
|
+
updateDoc,
|
|
10
|
+
where,
|
|
11
|
+
limit,
|
|
12
|
+
orderBy,
|
|
13
|
+
startAfter,
|
|
14
|
+
getCountFromServer,
|
|
15
|
+
QueryConstraint,
|
|
16
|
+
arrayUnion,
|
|
17
|
+
arrayRemove,
|
|
18
|
+
} from 'firebase/firestore';
|
|
19
|
+
import { Product, PRODUCTS_COLLECTION, IProductService } from '../types/product.types';
|
|
20
|
+
import { BaseService } from '../../services/base.service';
|
|
21
|
+
import { TECHNOLOGIES_COLLECTION } from '../types/technology.types';
|
|
22
|
+
|
|
23
|
+
export class ProductService extends BaseService implements IProductService {
|
|
24
|
+
/**
|
|
25
|
+
* Gets reference to top-level products collection (source of truth)
|
|
26
|
+
* @returns Firestore collection reference
|
|
27
|
+
*/
|
|
28
|
+
private getTopLevelProductsRef() {
|
|
29
|
+
return collection(this.db, PRODUCTS_COLLECTION);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gets reference to products collection under a technology (backward compatibility)
|
|
34
|
+
* @param technologyId - ID of the technology
|
|
35
|
+
* @returns Firestore collection reference
|
|
36
|
+
*/
|
|
37
|
+
private getProductsRef(technologyId: string) {
|
|
38
|
+
return collection(this.db, TECHNOLOGIES_COLLECTION, technologyId, PRODUCTS_COLLECTION);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a new product under technology
|
|
43
|
+
*/
|
|
44
|
+
async create(
|
|
45
|
+
technologyId: string,
|
|
46
|
+
brandId: string,
|
|
47
|
+
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'technologyId'>,
|
|
48
|
+
): Promise<Product> {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
// Create product with legacy structure for subcollection compatibility
|
|
51
|
+
const newProduct: Omit<Product, 'id'> = {
|
|
52
|
+
...product,
|
|
53
|
+
brandId,
|
|
54
|
+
technologyId, // Required for old subcollection structure
|
|
55
|
+
createdAt: now,
|
|
56
|
+
updatedAt: now,
|
|
57
|
+
isActive: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const productRef = await addDoc(this.getProductsRef(technologyId), newProduct);
|
|
61
|
+
|
|
62
|
+
return { id: productRef.id, ...newProduct };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Gets a paginated list of all products, with optional filters.
|
|
67
|
+
* This uses a collectionGroup query to search across all technologies.
|
|
68
|
+
*/
|
|
69
|
+
async getAll(options: {
|
|
70
|
+
rowsPerPage: number;
|
|
71
|
+
lastVisible?: any;
|
|
72
|
+
categoryId?: string;
|
|
73
|
+
subcategoryId?: string;
|
|
74
|
+
technologyId?: string;
|
|
75
|
+
}): Promise<{ products: Product[]; lastVisible: any }> {
|
|
76
|
+
const { rowsPerPage, lastVisible, categoryId, subcategoryId, technologyId } = options;
|
|
77
|
+
|
|
78
|
+
const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
|
|
79
|
+
|
|
80
|
+
if (categoryId) {
|
|
81
|
+
constraints.push(where('categoryId', '==', categoryId));
|
|
82
|
+
}
|
|
83
|
+
if (subcategoryId) {
|
|
84
|
+
constraints.push(where('subcategoryId', '==', subcategoryId));
|
|
85
|
+
}
|
|
86
|
+
if (technologyId) {
|
|
87
|
+
constraints.push(where('technologyId', '==', technologyId));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (lastVisible) {
|
|
91
|
+
constraints.push(startAfter(lastVisible));
|
|
92
|
+
}
|
|
93
|
+
constraints.push(limit(rowsPerPage));
|
|
94
|
+
|
|
95
|
+
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), ...constraints);
|
|
96
|
+
const snapshot = await getDocs(q);
|
|
97
|
+
|
|
98
|
+
const products = snapshot.docs.map(
|
|
99
|
+
doc =>
|
|
100
|
+
({
|
|
101
|
+
id: doc.id,
|
|
102
|
+
...doc.data(),
|
|
103
|
+
} as Product),
|
|
104
|
+
);
|
|
105
|
+
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
106
|
+
|
|
107
|
+
return { products, lastVisible: newLastVisible };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Gets the total count of active products, with optional filters.
|
|
112
|
+
*/
|
|
113
|
+
async getProductsCount(options: {
|
|
114
|
+
categoryId?: string;
|
|
115
|
+
subcategoryId?: string;
|
|
116
|
+
technologyId?: string;
|
|
117
|
+
}): Promise<number> {
|
|
118
|
+
const { categoryId, subcategoryId, technologyId } = options;
|
|
119
|
+
const constraints: QueryConstraint[] = [where('isActive', '==', true)];
|
|
120
|
+
|
|
121
|
+
if (categoryId) {
|
|
122
|
+
constraints.push(where('categoryId', '==', categoryId));
|
|
123
|
+
}
|
|
124
|
+
if (subcategoryId) {
|
|
125
|
+
constraints.push(where('subcategoryId', '==', subcategoryId));
|
|
126
|
+
}
|
|
127
|
+
if (technologyId) {
|
|
128
|
+
constraints.push(where('technologyId', '==', technologyId));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), ...constraints);
|
|
132
|
+
const snapshot = await getCountFromServer(q);
|
|
133
|
+
return snapshot.data().count;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Gets counts of active products grouped by category, subcategory, and technology.
|
|
138
|
+
* Queries technology subcollections which have the legacy fields synced by Cloud Functions.
|
|
139
|
+
*/
|
|
140
|
+
async getProductCounts(): Promise<{
|
|
141
|
+
byCategory: Record<string, number>;
|
|
142
|
+
bySubcategory: Record<string, number>;
|
|
143
|
+
byTechnology: Record<string, number>;
|
|
144
|
+
}> {
|
|
145
|
+
const counts = {
|
|
146
|
+
byCategory: {} as Record<string, number>,
|
|
147
|
+
bySubcategory: {} as Record<string, number>,
|
|
148
|
+
byTechnology: {} as Record<string, number>,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Query technology subcollections (which have synced legacy fields)
|
|
152
|
+
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
|
|
153
|
+
const snapshot = await getDocs(q);
|
|
154
|
+
|
|
155
|
+
snapshot.docs.forEach(doc => {
|
|
156
|
+
const product = doc.data() as Product;
|
|
157
|
+
|
|
158
|
+
// Use legacy fields from subcollections
|
|
159
|
+
if (product.categoryId) {
|
|
160
|
+
counts.byCategory[product.categoryId] = (counts.byCategory[product.categoryId] || 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
if (product.subcategoryId) {
|
|
163
|
+
counts.bySubcategory[product.subcategoryId] = (counts.bySubcategory[product.subcategoryId] || 0) + 1;
|
|
164
|
+
}
|
|
165
|
+
if (product.technologyId) {
|
|
166
|
+
counts.byTechnology[product.technologyId] = (counts.byTechnology[product.technologyId] || 0) + 1;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return counts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Gets all products for a specific technology (non-paginated, for filters/dropdowns)
|
|
175
|
+
*/
|
|
176
|
+
async getAllByTechnology(technologyId: string): Promise<Product[]> {
|
|
177
|
+
const q = query(
|
|
178
|
+
this.getProductsRef(technologyId),
|
|
179
|
+
where('isActive', '==', true),
|
|
180
|
+
orderBy('name'),
|
|
181
|
+
);
|
|
182
|
+
const snapshot = await getDocs(q);
|
|
183
|
+
return snapshot.docs.map(
|
|
184
|
+
doc =>
|
|
185
|
+
({
|
|
186
|
+
id: doc.id,
|
|
187
|
+
...doc.data(),
|
|
188
|
+
} as Product),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Gets all products for a brand by filtering through all technologies
|
|
194
|
+
*/
|
|
195
|
+
async getAllByBrand(brandId: string): Promise<Product[]> {
|
|
196
|
+
const allTechnologiesRef = collection(this.db, TECHNOLOGIES_COLLECTION);
|
|
197
|
+
const technologiesSnapshot = await getDocs(allTechnologiesRef);
|
|
198
|
+
|
|
199
|
+
const products: Product[] = [];
|
|
200
|
+
|
|
201
|
+
for (const techDoc of technologiesSnapshot.docs) {
|
|
202
|
+
const q = query(
|
|
203
|
+
this.getProductsRef(techDoc.id),
|
|
204
|
+
where('brandId', '==', brandId),
|
|
205
|
+
where('isActive', '==', true),
|
|
206
|
+
);
|
|
207
|
+
const snapshot = await getDocs(q);
|
|
208
|
+
products.push(
|
|
209
|
+
...snapshot.docs.map(
|
|
210
|
+
doc =>
|
|
211
|
+
({
|
|
212
|
+
id: doc.id,
|
|
213
|
+
...doc.data(),
|
|
214
|
+
} as Product),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return products;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Updates a product
|
|
224
|
+
*/
|
|
225
|
+
async update(
|
|
226
|
+
technologyId: string,
|
|
227
|
+
productId: string,
|
|
228
|
+
product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId' | 'technologyId'>>,
|
|
229
|
+
): Promise<Product | null> {
|
|
230
|
+
const updateData = {
|
|
231
|
+
...product,
|
|
232
|
+
updatedAt: new Date(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const docRef = doc(this.getProductsRef(technologyId), productId);
|
|
236
|
+
await updateDoc(docRef, updateData);
|
|
237
|
+
|
|
238
|
+
return this.getById(technologyId, productId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Soft deletes a product
|
|
243
|
+
*/
|
|
244
|
+
async delete(technologyId: string, productId: string): Promise<void> {
|
|
245
|
+
await this.update(technologyId, productId, {
|
|
246
|
+
isActive: false,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Gets a product by ID
|
|
252
|
+
*/
|
|
253
|
+
async getById(technologyId: string, productId: string): Promise<Product | null> {
|
|
254
|
+
const docRef = doc(this.getProductsRef(technologyId), productId);
|
|
255
|
+
const docSnap = await getDoc(docRef);
|
|
256
|
+
if (!docSnap.exists()) return null;
|
|
257
|
+
return {
|
|
258
|
+
id: docSnap.id,
|
|
259
|
+
...docSnap.data(),
|
|
260
|
+
} as Product;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ==========================================
|
|
264
|
+
// NEW METHODS: Top-level collection (preferred)
|
|
265
|
+
// ==========================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Creates a new product in the top-level collection
|
|
269
|
+
*/
|
|
270
|
+
async createTopLevel(
|
|
271
|
+
brandId: string,
|
|
272
|
+
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
|
|
273
|
+
technologyIds: string[] = [],
|
|
274
|
+
): Promise<Product> {
|
|
275
|
+
const now = new Date();
|
|
276
|
+
const newProduct: Omit<Product, 'id'> = {
|
|
277
|
+
...product,
|
|
278
|
+
brandId,
|
|
279
|
+
assignedTechnologyIds: technologyIds,
|
|
280
|
+
createdAt: now,
|
|
281
|
+
updatedAt: now,
|
|
282
|
+
isActive: true,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const productRef = await addDoc(this.getTopLevelProductsRef(), newProduct);
|
|
286
|
+
return { id: productRef.id, ...newProduct };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Gets all products from the top-level collection
|
|
291
|
+
*/
|
|
292
|
+
async getAllTopLevel(options: {
|
|
293
|
+
rowsPerPage: number;
|
|
294
|
+
lastVisible?: any;
|
|
295
|
+
brandId?: string;
|
|
296
|
+
}): Promise<{ products: Product[]; lastVisible: any }> {
|
|
297
|
+
const { rowsPerPage, lastVisible, brandId } = options;
|
|
298
|
+
|
|
299
|
+
const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
|
|
300
|
+
|
|
301
|
+
if (brandId) {
|
|
302
|
+
constraints.push(where('brandId', '==', brandId));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (lastVisible) {
|
|
306
|
+
constraints.push(startAfter(lastVisible));
|
|
307
|
+
}
|
|
308
|
+
constraints.push(limit(rowsPerPage));
|
|
309
|
+
|
|
310
|
+
const q = query(this.getTopLevelProductsRef(), ...constraints);
|
|
311
|
+
const snapshot = await getDocs(q);
|
|
312
|
+
|
|
313
|
+
const products = snapshot.docs.map(
|
|
314
|
+
doc =>
|
|
315
|
+
({
|
|
316
|
+
id: doc.id,
|
|
317
|
+
...doc.data(),
|
|
318
|
+
} as Product),
|
|
319
|
+
);
|
|
320
|
+
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
321
|
+
|
|
322
|
+
return { products, lastVisible: newLastVisible };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Gets a product by ID from the top-level collection
|
|
327
|
+
*/
|
|
328
|
+
async getByIdTopLevel(productId: string): Promise<Product | null> {
|
|
329
|
+
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
330
|
+
const docSnap = await getDoc(docRef);
|
|
331
|
+
if (!docSnap.exists()) return null;
|
|
332
|
+
return {
|
|
333
|
+
id: docSnap.id,
|
|
334
|
+
...docSnap.data(),
|
|
335
|
+
} as Product;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Updates a product in the top-level collection
|
|
340
|
+
*/
|
|
341
|
+
async updateTopLevel(
|
|
342
|
+
productId: string,
|
|
343
|
+
product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
|
|
344
|
+
): Promise<Product | null> {
|
|
345
|
+
const updateData = {
|
|
346
|
+
...product,
|
|
347
|
+
updatedAt: new Date(),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
351
|
+
await updateDoc(docRef, updateData);
|
|
352
|
+
|
|
353
|
+
return this.getByIdTopLevel(productId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Deletes a product from the top-level collection (soft delete)
|
|
358
|
+
*/
|
|
359
|
+
async deleteTopLevel(productId: string): Promise<void> {
|
|
360
|
+
await this.updateTopLevel(productId, {
|
|
361
|
+
isActive: false,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Assigns a product to a technology
|
|
367
|
+
*/
|
|
368
|
+
async assignToTechnology(productId: string, technologyId: string): Promise<void> {
|
|
369
|
+
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
370
|
+
await updateDoc(docRef, {
|
|
371
|
+
assignedTechnologyIds: arrayUnion(technologyId),
|
|
372
|
+
updatedAt: new Date(),
|
|
373
|
+
});
|
|
374
|
+
// Cloud Function will handle syncing to subcollection
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Unassigns a product from a technology
|
|
379
|
+
*/
|
|
380
|
+
async unassignFromTechnology(productId: string, technologyId: string): Promise<void> {
|
|
381
|
+
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
382
|
+
await updateDoc(docRef, {
|
|
383
|
+
assignedTechnologyIds: arrayRemove(technologyId),
|
|
384
|
+
updatedAt: new Date(),
|
|
385
|
+
});
|
|
386
|
+
// Cloud Function will handle removing from subcollection
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Gets products assigned to a specific technology
|
|
391
|
+
*/
|
|
392
|
+
async getAssignedProducts(technologyId: string): Promise<Product[]> {
|
|
393
|
+
const q = query(
|
|
394
|
+
this.getTopLevelProductsRef(),
|
|
395
|
+
where('assignedTechnologyIds', 'array-contains', technologyId),
|
|
396
|
+
where('isActive', '==', true),
|
|
397
|
+
orderBy('name'),
|
|
398
|
+
);
|
|
399
|
+
const snapshot = await getDocs(q);
|
|
400
|
+
return snapshot.docs.map(
|
|
401
|
+
doc =>
|
|
402
|
+
({
|
|
403
|
+
id: doc.id,
|
|
404
|
+
...doc.data(),
|
|
405
|
+
} as Product),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Gets products NOT assigned to a specific technology
|
|
411
|
+
*/
|
|
412
|
+
async getUnassignedProducts(technologyId: string): Promise<Product[]> {
|
|
413
|
+
const q = query(
|
|
414
|
+
this.getTopLevelProductsRef(),
|
|
415
|
+
where('isActive', '==', true),
|
|
416
|
+
orderBy('name'),
|
|
417
|
+
);
|
|
418
|
+
const snapshot = await getDocs(q);
|
|
419
|
+
|
|
420
|
+
const allProducts = snapshot.docs.map(
|
|
421
|
+
doc =>
|
|
422
|
+
({
|
|
423
|
+
id: doc.id,
|
|
424
|
+
...doc.data(),
|
|
425
|
+
} as Product),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Filter out products already assigned to this technology
|
|
429
|
+
return allProducts.filter(product =>
|
|
430
|
+
!product.assignedTechnologyIds?.includes(technologyId)
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Gets all products for a brand (from top-level collection)
|
|
436
|
+
*/
|
|
437
|
+
async getByBrand(brandId: string): Promise<Product[]> {
|
|
438
|
+
const q = query(
|
|
439
|
+
this.getTopLevelProductsRef(),
|
|
440
|
+
where('brandId', '==', brandId),
|
|
441
|
+
where('isActive', '==', true),
|
|
442
|
+
orderBy('name'),
|
|
443
|
+
);
|
|
444
|
+
const snapshot = await getDocs(q);
|
|
445
|
+
return snapshot.docs.map(
|
|
446
|
+
doc =>
|
|
447
|
+
({
|
|
448
|
+
id: doc.id,
|
|
449
|
+
...doc.data(),
|
|
450
|
+
} as Product),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Exports products to CSV string, suitable for Excel/Sheets.
|
|
456
|
+
* Includes headers and optional UTF-8 BOM.
|
|
457
|
+
* By default exports only active products (set includeInactive to true to export all).
|
|
458
|
+
*/
|
|
459
|
+
async exportToCsv(options?: {
|
|
460
|
+
includeInactive?: boolean;
|
|
461
|
+
includeBom?: boolean;
|
|
462
|
+
}): Promise<string> {
|
|
463
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
464
|
+
const includeBom = options?.includeBom ?? true;
|
|
465
|
+
|
|
466
|
+
const headers = [
|
|
467
|
+
"id",
|
|
468
|
+
"name",
|
|
469
|
+
"brandId",
|
|
470
|
+
"brandName",
|
|
471
|
+
"assignedTechnologyIds",
|
|
472
|
+
"description",
|
|
473
|
+
"technicalDetails",
|
|
474
|
+
"dosage",
|
|
475
|
+
"composition",
|
|
476
|
+
"indications",
|
|
477
|
+
"contraindications",
|
|
478
|
+
"warnings",
|
|
479
|
+
"isActive",
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
const rows: string[] = [];
|
|
483
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
484
|
+
|
|
485
|
+
const PAGE_SIZE = 1000;
|
|
486
|
+
let cursor: any | undefined;
|
|
487
|
+
|
|
488
|
+
// Build base constraints
|
|
489
|
+
const constraints: QueryConstraint[] = [];
|
|
490
|
+
if (!includeInactive) {
|
|
491
|
+
constraints.push(where("isActive", "==", true));
|
|
492
|
+
}
|
|
493
|
+
constraints.push(orderBy("name"));
|
|
494
|
+
|
|
495
|
+
// Page through all results from top-level collection
|
|
496
|
+
// eslint-disable-next-line no-constant-condition
|
|
497
|
+
while (true) {
|
|
498
|
+
const queryConstraints: QueryConstraint[] = [...constraints, limit(PAGE_SIZE)];
|
|
499
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
500
|
+
|
|
501
|
+
const q = query(this.getTopLevelProductsRef(), ...queryConstraints);
|
|
502
|
+
const snapshot = await getDocs(q);
|
|
503
|
+
if (snapshot.empty) break;
|
|
504
|
+
|
|
505
|
+
for (const d of snapshot.docs) {
|
|
506
|
+
const product = ({ id: d.id, ...d.data() } as unknown) as Product;
|
|
507
|
+
rows.push(this.productToCsvRow(product));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
511
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const csvBody = rows.join("\r\n");
|
|
515
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private productToCsvRow(product: Product): string {
|
|
519
|
+
const values = [
|
|
520
|
+
product.id ?? "",
|
|
521
|
+
product.name ?? "",
|
|
522
|
+
product.brandId ?? "",
|
|
523
|
+
product.brandName ?? "",
|
|
524
|
+
product.assignedTechnologyIds?.join(";") ?? "",
|
|
525
|
+
product.description ?? "",
|
|
526
|
+
product.technicalDetails ?? "",
|
|
527
|
+
product.dosage ?? "",
|
|
528
|
+
product.composition ?? "",
|
|
529
|
+
product.indications?.join(";") ?? "",
|
|
530
|
+
product.contraindications?.map(c => c.name).join(";") ?? "",
|
|
531
|
+
product.warnings?.join(";") ?? "",
|
|
532
|
+
String(product.isActive ?? ""),
|
|
533
|
+
];
|
|
534
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private formatDateIso(value: any): string {
|
|
538
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
539
|
+
if (value instanceof Date) return value.toISOString();
|
|
540
|
+
if (value && typeof value.toDate === "function") {
|
|
541
|
+
const d = value.toDate();
|
|
542
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
543
|
+
}
|
|
544
|
+
return String(value ?? "");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private formatCsvValue(value: any): string {
|
|
548
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
549
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
550
|
+
const escaped = str.replace(/"/g, '""');
|
|
551
|
+
return `"${escaped}"`;
|
|
552
|
+
}
|
|
553
|
+
}
|