@blackcode_sa/metaestetics-api 1.13.5 → 1.13.6
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 +20 -1
- package/dist/admin/index.d.ts +20 -1
- package/dist/admin/index.js +217 -1
- package/dist/admin/index.mjs +217 -1
- package/package.json +121 -121
- 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 +1984 -1984
- 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 +966 -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 +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- 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 +81 -81
- 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 +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +384 -384
- 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 +10 -10
- 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 +461 -461
- package/src/backoffice/services/technology.service.ts +1151 -1151
- 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 +67 -67
- 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 +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- 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/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2142 -2142
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2558 -2558
- 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 +14 -14
- 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 +2200 -2200
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +734 -734
- 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/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +480 -480
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +498 -498
- 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 +47 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +286 -286
- 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 -275
- 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 +132 -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 +494 -494
- 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 -217
- 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 +195 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,461 +1,461 @@
|
|
|
1
|
-
import {
|
|
2
|
-
addDoc,
|
|
3
|
-
collection,
|
|
4
|
-
collectionGroup,
|
|
5
|
-
deleteDoc,
|
|
6
|
-
doc,
|
|
7
|
-
DocumentData,
|
|
8
|
-
getCountFromServer,
|
|
9
|
-
getDoc,
|
|
10
|
-
getDocs,
|
|
11
|
-
limit,
|
|
12
|
-
orderBy,
|
|
13
|
-
query,
|
|
14
|
-
setDoc,
|
|
15
|
-
startAfter,
|
|
16
|
-
updateDoc,
|
|
17
|
-
where,
|
|
18
|
-
} from "firebase/firestore";
|
|
19
|
-
import {
|
|
20
|
-
Subcategory,
|
|
21
|
-
SUBCATEGORIES_COLLECTION,
|
|
22
|
-
} from "../types/subcategory.types";
|
|
23
|
-
import { BaseService } from "../../services/base.service";
|
|
24
|
-
import { CATEGORIES_COLLECTION } from "../types/category.types";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* ID of the free-consultation subcategory that should be hidden from admin backoffice.
|
|
28
|
-
* This subcategory is used internally for free consultation procedures.
|
|
29
|
-
*/
|
|
30
|
-
const EXCLUDED_SUBCATEGORY_ID = 'free-consultation';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Servis za upravljanje podkategorijama procedura.
|
|
34
|
-
* Podkategorije su drugi nivo organizacije i pripadaju određenoj kategoriji.
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* const subcategoryService = new SubcategoryService();
|
|
38
|
-
*
|
|
39
|
-
* // Kreiranje nove podkategorije
|
|
40
|
-
* const subcategory = await subcategoryService.create(categoryId, {
|
|
41
|
-
* name: "Anti-Wrinkle",
|
|
42
|
-
* description: "Treatments targeting facial wrinkles"
|
|
43
|
-
* });
|
|
44
|
-
*/
|
|
45
|
-
export class SubcategoryService extends BaseService {
|
|
46
|
-
/**
|
|
47
|
-
* Filters out excluded subcategories from a list.
|
|
48
|
-
* @param subcategories - List of subcategories to filter
|
|
49
|
-
* @returns Filtered list without excluded subcategories
|
|
50
|
-
*/
|
|
51
|
-
private filterExcludedSubcategories(subcategories: Subcategory[]): Subcategory[] {
|
|
52
|
-
return subcategories.filter(sub => sub.id !== EXCLUDED_SUBCATEGORY_ID);
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Vraća referencu na Firestore kolekciju podkategorija za određenu kategoriju
|
|
56
|
-
* @param categoryId - ID roditeljske kategorije
|
|
57
|
-
*/
|
|
58
|
-
private getSubcategoriesRef(categoryId: string) {
|
|
59
|
-
return collection(
|
|
60
|
-
this.db,
|
|
61
|
-
CATEGORIES_COLLECTION,
|
|
62
|
-
categoryId,
|
|
63
|
-
SUBCATEGORIES_COLLECTION
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Kreira novu podkategoriju u okviru kategorije
|
|
69
|
-
* @param categoryId - ID kategorije kojoj će pripadati nova podkategorija
|
|
70
|
-
* @param subcategory - Podaci za novu podkategoriju
|
|
71
|
-
* @returns Kreirana podkategorija sa generisanim ID-em
|
|
72
|
-
*/
|
|
73
|
-
async create(
|
|
74
|
-
categoryId: string,
|
|
75
|
-
subcategory: Omit<Subcategory, "id" | "createdAt" | "updatedAt">
|
|
76
|
-
) {
|
|
77
|
-
const now = new Date();
|
|
78
|
-
const newSubcategory: Omit<Subcategory, "id"> = {
|
|
79
|
-
...subcategory,
|
|
80
|
-
categoryId,
|
|
81
|
-
createdAt: now,
|
|
82
|
-
updatedAt: now,
|
|
83
|
-
isActive: true,
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const docRef = await addDoc(
|
|
87
|
-
this.getSubcategoriesRef(categoryId),
|
|
88
|
-
newSubcategory
|
|
89
|
-
);
|
|
90
|
-
return { id: docRef.id, ...newSubcategory };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Returns counts of subcategories for all categories.
|
|
95
|
-
* @param active - Whether to count active or inactive subcategories.
|
|
96
|
-
* @returns A record mapping category ID to subcategory count.
|
|
97
|
-
*/
|
|
98
|
-
async getSubcategoryCounts(active = true) {
|
|
99
|
-
const categoriesRef = collection(this.db, CATEGORIES_COLLECTION);
|
|
100
|
-
const categoriesSnapshot = await getDocs(categoriesRef);
|
|
101
|
-
const counts: Record<string, number> = {};
|
|
102
|
-
|
|
103
|
-
for (const categoryDoc of categoriesSnapshot.docs) {
|
|
104
|
-
const categoryId = categoryDoc.id;
|
|
105
|
-
const subcategoriesRef = this.getSubcategoriesRef(categoryId);
|
|
106
|
-
const q = query(subcategoriesRef, where("isActive", "==", active));
|
|
107
|
-
const snapshot = await getDocs(q);
|
|
108
|
-
// Filter out excluded subcategory and count
|
|
109
|
-
const filteredDocs = snapshot.docs.filter(doc => doc.id !== EXCLUDED_SUBCATEGORY_ID);
|
|
110
|
-
counts[categoryId] = filteredDocs.length;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return counts;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Vraća sve aktivne podkategorije za određenu kategoriju sa paginacijom
|
|
118
|
-
* @param categoryId - ID kategorije čije podkategorije tražimo
|
|
119
|
-
* @param options - Pagination options
|
|
120
|
-
* @returns Lista aktivnih podkategorija i poslednji vidljiv dokument
|
|
121
|
-
*/
|
|
122
|
-
async getAllByCategoryId(
|
|
123
|
-
categoryId: string,
|
|
124
|
-
options: {
|
|
125
|
-
active?: boolean;
|
|
126
|
-
limit?: number;
|
|
127
|
-
lastVisible?: DocumentData;
|
|
128
|
-
} = {}
|
|
129
|
-
) {
|
|
130
|
-
const { active = true, limit: queryLimit = 10, lastVisible } = options;
|
|
131
|
-
const constraints = [
|
|
132
|
-
where("isActive", "==", active),
|
|
133
|
-
orderBy("name"),
|
|
134
|
-
queryLimit ? limit(queryLimit) : undefined,
|
|
135
|
-
lastVisible ? startAfter(lastVisible) : undefined,
|
|
136
|
-
].filter((c): c is NonNullable<typeof c> => !!c);
|
|
137
|
-
|
|
138
|
-
const q = query(this.getSubcategoriesRef(categoryId), ...constraints);
|
|
139
|
-
|
|
140
|
-
const querySnapshot = await getDocs(q);
|
|
141
|
-
const subcategories = querySnapshot.docs.map(
|
|
142
|
-
(doc) =>
|
|
143
|
-
({
|
|
144
|
-
id: doc.id,
|
|
145
|
-
...doc.data(),
|
|
146
|
-
} as Subcategory)
|
|
147
|
-
);
|
|
148
|
-
const filteredSubcategories = this.filterExcludedSubcategories(subcategories);
|
|
149
|
-
const newLastVisible = querySnapshot.docs[querySnapshot.docs.length - 1];
|
|
150
|
-
return { subcategories: filteredSubcategories, lastVisible: newLastVisible };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Vraća sve podkategorije sa paginacijom koristeći collection group query.
|
|
155
|
-
* NOTE: This query requires a composite index in Firestore on the 'subcategories' collection group.
|
|
156
|
-
* The index should be on 'isActive' (ascending) and 'name' (ascending).
|
|
157
|
-
* Firestore will provide a link to create this index in the console error if it's missing.
|
|
158
|
-
* @param options - Pagination options
|
|
159
|
-
* @returns Lista podkategorija i poslednji vidljiv dokument
|
|
160
|
-
*/
|
|
161
|
-
async getAll(
|
|
162
|
-
options: {
|
|
163
|
-
active?: boolean;
|
|
164
|
-
limit?: number;
|
|
165
|
-
lastVisible?: DocumentData;
|
|
166
|
-
} = {}
|
|
167
|
-
) {
|
|
168
|
-
const { active = true, limit: queryLimit = 10, lastVisible } = options;
|
|
169
|
-
const constraints = [
|
|
170
|
-
where("isActive", "==", active),
|
|
171
|
-
orderBy("name"),
|
|
172
|
-
queryLimit ? limit(queryLimit) : undefined,
|
|
173
|
-
lastVisible ? startAfter(lastVisible) : undefined,
|
|
174
|
-
].filter((c): c is NonNullable<typeof c> => !!c);
|
|
175
|
-
|
|
176
|
-
const q = query(
|
|
177
|
-
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
178
|
-
...constraints
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
const querySnapshot = await getDocs(q);
|
|
182
|
-
const subcategories = querySnapshot.docs.map(
|
|
183
|
-
(doc) =>
|
|
184
|
-
({
|
|
185
|
-
id: doc.id,
|
|
186
|
-
...doc.data(),
|
|
187
|
-
} as Subcategory)
|
|
188
|
-
);
|
|
189
|
-
const filteredSubcategories = this.filterExcludedSubcategories(subcategories);
|
|
190
|
-
const newLastVisible = querySnapshot.docs[querySnapshot.docs.length - 1];
|
|
191
|
-
return { subcategories: filteredSubcategories, lastVisible: newLastVisible };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Vraća sve subkategorije za određenu kategoriju za potrebe filtera (bez paginacije)
|
|
196
|
-
* @param categoryId - ID kategorije čije subkategorije tražimo
|
|
197
|
-
* @returns Lista svih aktivnih subkategorija
|
|
198
|
-
*/
|
|
199
|
-
async getAllForFilterByCategoryId(categoryId: string) {
|
|
200
|
-
const q = query(
|
|
201
|
-
this.getSubcategoriesRef(categoryId),
|
|
202
|
-
where("isActive", "==", true)
|
|
203
|
-
);
|
|
204
|
-
const querySnapshot = await getDocs(q);
|
|
205
|
-
const subcategories = querySnapshot.docs.map(
|
|
206
|
-
(doc) =>
|
|
207
|
-
({
|
|
208
|
-
id: doc.id,
|
|
209
|
-
...doc.data(),
|
|
210
|
-
} as Subcategory)
|
|
211
|
-
);
|
|
212
|
-
return this.filterExcludedSubcategories(subcategories);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Vraća sve subkategorije za potrebe filtera (bez paginacije)
|
|
217
|
-
* @returns Lista svih aktivnih subkategorija
|
|
218
|
-
*/
|
|
219
|
-
async getAllForFilter() {
|
|
220
|
-
const q = query(
|
|
221
|
-
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
222
|
-
where("isActive", "==", true)
|
|
223
|
-
);
|
|
224
|
-
const querySnapshot = await getDocs(q);
|
|
225
|
-
const subcategories = querySnapshot.docs.map(
|
|
226
|
-
(doc) =>
|
|
227
|
-
({
|
|
228
|
-
id: doc.id,
|
|
229
|
-
...doc.data(),
|
|
230
|
-
} as Subcategory)
|
|
231
|
-
);
|
|
232
|
-
return this.filterExcludedSubcategories(subcategories);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Ažurira postojeću podkategoriju
|
|
237
|
-
* @param categoryId - ID kategorije kojoj pripada podkategorija
|
|
238
|
-
* @param subcategoryId - ID podkategorije koja se ažurira
|
|
239
|
-
* @param subcategory - Novi podaci za podkategoriju
|
|
240
|
-
* @returns Ažurirana podkategorija
|
|
241
|
-
*/
|
|
242
|
-
async update(
|
|
243
|
-
categoryId: string,
|
|
244
|
-
subcategoryId: string,
|
|
245
|
-
subcategory: Partial<Omit<Subcategory, "id" | "createdAt">>
|
|
246
|
-
) {
|
|
247
|
-
const newCategoryId = subcategory.categoryId;
|
|
248
|
-
|
|
249
|
-
if (newCategoryId && newCategoryId !== categoryId) {
|
|
250
|
-
// Category has changed, move the document
|
|
251
|
-
const oldDocRef = doc(
|
|
252
|
-
this.getSubcategoriesRef(categoryId),
|
|
253
|
-
subcategoryId
|
|
254
|
-
);
|
|
255
|
-
const docSnap = await getDoc(oldDocRef);
|
|
256
|
-
|
|
257
|
-
if (!docSnap.exists()) {
|
|
258
|
-
throw new Error("Subcategory to update does not exist.");
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const existingData = docSnap.data();
|
|
262
|
-
const newData: Omit<Subcategory, "id"> = {
|
|
263
|
-
...(existingData as Omit<
|
|
264
|
-
Subcategory,
|
|
265
|
-
"id" | "createdAt" | "updatedAt"
|
|
266
|
-
>),
|
|
267
|
-
...subcategory,
|
|
268
|
-
categoryId: newCategoryId, // Ensure categoryId is updated
|
|
269
|
-
createdAt: existingData.createdAt, // Preserve original creation date
|
|
270
|
-
updatedAt: new Date(),
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const newDocRef = doc(
|
|
274
|
-
this.getSubcategoriesRef(newCategoryId),
|
|
275
|
-
subcategoryId
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
await setDoc(newDocRef, newData);
|
|
279
|
-
await deleteDoc(oldDocRef);
|
|
280
|
-
|
|
281
|
-
return { id: subcategoryId, ...newData };
|
|
282
|
-
} else {
|
|
283
|
-
// Category has not changed, just update the document
|
|
284
|
-
const updateData = {
|
|
285
|
-
...subcategory,
|
|
286
|
-
updatedAt: new Date(),
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
290
|
-
await updateDoc(docRef, updateData);
|
|
291
|
-
return this.getById(categoryId, subcategoryId);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Soft delete podkategorije (postavlja isActive na false)
|
|
297
|
-
* @param categoryId - ID kategorije kojoj pripada podkategorija
|
|
298
|
-
* @param subcategoryId - ID podkategorije koja se briše
|
|
299
|
-
*/
|
|
300
|
-
async delete(categoryId: string, subcategoryId: string) {
|
|
301
|
-
await this.update(categoryId, subcategoryId, { isActive: false });
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Reactivates a subcategory by setting its isActive flag to true.
|
|
306
|
-
* @param categoryId - The ID of the category to which the subcategory belongs.
|
|
307
|
-
* @param subcategoryId - The ID of the subcategory to reactivate.
|
|
308
|
-
*/
|
|
309
|
-
async reactivate(categoryId: string, subcategoryId: string) {
|
|
310
|
-
await this.update(categoryId, subcategoryId, { isActive: true });
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Vraća podkategoriju po ID-u
|
|
315
|
-
* @param categoryId - ID kategorije kojoj pripada podkategorija
|
|
316
|
-
* @param subcategoryId - ID tražene podkategorije
|
|
317
|
-
* @returns Podkategorija ili null ako ne postoji
|
|
318
|
-
*/
|
|
319
|
-
async getById(categoryId: string, subcategoryId: string) {
|
|
320
|
-
// Prevent access to excluded subcategory
|
|
321
|
-
if (subcategoryId === EXCLUDED_SUBCATEGORY_ID) return null;
|
|
322
|
-
|
|
323
|
-
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
324
|
-
const docSnap = await getDoc(docRef);
|
|
325
|
-
if (!docSnap.exists()) return null;
|
|
326
|
-
return {
|
|
327
|
-
id: docSnap.id,
|
|
328
|
-
...docSnap.data(),
|
|
329
|
-
} as Subcategory;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Internal method to get subcategory by ID without filtering.
|
|
334
|
-
* Used internally for consultation procedures.
|
|
335
|
-
* @param categoryId - ID of the category
|
|
336
|
-
* @param subcategoryId - ID of the subcategory to get
|
|
337
|
-
* @returns Subcategory or null if not found
|
|
338
|
-
*/
|
|
339
|
-
async getByIdInternal(categoryId: string, subcategoryId: string): Promise<Subcategory | null> {
|
|
340
|
-
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
341
|
-
const docSnap = await getDoc(docRef);
|
|
342
|
-
if (!docSnap.exists()) return null;
|
|
343
|
-
return {
|
|
344
|
-
id: docSnap.id,
|
|
345
|
-
...docSnap.data(),
|
|
346
|
-
} as Subcategory;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Finds a subcategory by exact name match within a specific category.
|
|
351
|
-
* Used for CSV import matching.
|
|
352
|
-
* @param name - Exact name of the subcategory to find
|
|
353
|
-
* @param categoryId - ID of the category to search within
|
|
354
|
-
* @returns Subcategory if found, null otherwise
|
|
355
|
-
*/
|
|
356
|
-
async findByNameAndCategory(name: string, categoryId: string): Promise<Subcategory | null> {
|
|
357
|
-
const q = query(
|
|
358
|
-
this.getSubcategoriesRef(categoryId),
|
|
359
|
-
where('name', '==', name),
|
|
360
|
-
where('isActive', '==', true),
|
|
361
|
-
);
|
|
362
|
-
const querySnapshot = await getDocs(q);
|
|
363
|
-
if (querySnapshot.empty) return null;
|
|
364
|
-
const doc = querySnapshot.docs[0];
|
|
365
|
-
// Exclude free-consultation subcategory
|
|
366
|
-
if (doc.id === EXCLUDED_SUBCATEGORY_ID) return null;
|
|
367
|
-
return {
|
|
368
|
-
id: doc.id,
|
|
369
|
-
...doc.data(),
|
|
370
|
-
} as Subcategory;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Exports subcategories to CSV string, suitable for Excel/Sheets.
|
|
375
|
-
* Includes headers and optional UTF-8 BOM.
|
|
376
|
-
* By default exports only active subcategories (set includeInactive to true to export all).
|
|
377
|
-
*/
|
|
378
|
-
async exportToCsv(options?: {
|
|
379
|
-
includeInactive?: boolean;
|
|
380
|
-
includeBom?: boolean;
|
|
381
|
-
}): Promise<string> {
|
|
382
|
-
const includeInactive = options?.includeInactive ?? false;
|
|
383
|
-
const includeBom = options?.includeBom ?? true;
|
|
384
|
-
|
|
385
|
-
const headers = [
|
|
386
|
-
"id",
|
|
387
|
-
"name",
|
|
388
|
-
"categoryId",
|
|
389
|
-
"description",
|
|
390
|
-
"isActive",
|
|
391
|
-
];
|
|
392
|
-
|
|
393
|
-
const rows: string[] = [];
|
|
394
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
395
|
-
|
|
396
|
-
const PAGE_SIZE = 1000;
|
|
397
|
-
let cursor: any | undefined;
|
|
398
|
-
|
|
399
|
-
// Build base constraints
|
|
400
|
-
const constraints: any[] = [];
|
|
401
|
-
if (!includeInactive) {
|
|
402
|
-
constraints.push(where("isActive", "==", true));
|
|
403
|
-
}
|
|
404
|
-
constraints.push(orderBy("name"));
|
|
405
|
-
|
|
406
|
-
// Page through all results using collectionGroup
|
|
407
|
-
// eslint-disable-next-line no-constant-condition
|
|
408
|
-
while (true) {
|
|
409
|
-
const queryConstraints: any[] = [...constraints, limit(PAGE_SIZE)];
|
|
410
|
-
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
411
|
-
|
|
412
|
-
const q = query(
|
|
413
|
-
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
414
|
-
...queryConstraints
|
|
415
|
-
);
|
|
416
|
-
const snapshot = await getDocs(q);
|
|
417
|
-
if (snapshot.empty) break;
|
|
418
|
-
|
|
419
|
-
for (const d of snapshot.docs) {
|
|
420
|
-
// Exclude free-consultation subcategory from CSV export
|
|
421
|
-
if (d.id === EXCLUDED_SUBCATEGORY_ID) continue;
|
|
422
|
-
const subcategory = ({ id: d.id, ...d.data() } as unknown) as Subcategory;
|
|
423
|
-
rows.push(this.subcategoryToCsvRow(subcategory));
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
427
|
-
if (snapshot.size < PAGE_SIZE) break;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const csvBody = rows.join("\r\n");
|
|
431
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
private subcategoryToCsvRow(subcategory: Subcategory): string {
|
|
435
|
-
const values = [
|
|
436
|
-
subcategory.id ?? "",
|
|
437
|
-
subcategory.name ?? "",
|
|
438
|
-
subcategory.categoryId ?? "",
|
|
439
|
-
subcategory.description ?? "",
|
|
440
|
-
String(subcategory.isActive ?? ""),
|
|
441
|
-
];
|
|
442
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private formatDateIso(value: any): string {
|
|
446
|
-
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
447
|
-
if (value instanceof Date) return value.toISOString();
|
|
448
|
-
if (value && typeof value.toDate === "function") {
|
|
449
|
-
const d = value.toDate();
|
|
450
|
-
return d instanceof Date ? d.toISOString() : String(value);
|
|
451
|
-
}
|
|
452
|
-
return String(value ?? "");
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
private formatCsvValue(value: any): string {
|
|
456
|
-
const str = value === null || value === undefined ? "" : String(value);
|
|
457
|
-
// Escape double quotes by doubling them and wrap in quotes
|
|
458
|
-
const escaped = str.replace(/"/g, '""');
|
|
459
|
-
return `"${escaped}"`;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
addDoc,
|
|
3
|
+
collection,
|
|
4
|
+
collectionGroup,
|
|
5
|
+
deleteDoc,
|
|
6
|
+
doc,
|
|
7
|
+
DocumentData,
|
|
8
|
+
getCountFromServer,
|
|
9
|
+
getDoc,
|
|
10
|
+
getDocs,
|
|
11
|
+
limit,
|
|
12
|
+
orderBy,
|
|
13
|
+
query,
|
|
14
|
+
setDoc,
|
|
15
|
+
startAfter,
|
|
16
|
+
updateDoc,
|
|
17
|
+
where,
|
|
18
|
+
} from "firebase/firestore";
|
|
19
|
+
import {
|
|
20
|
+
Subcategory,
|
|
21
|
+
SUBCATEGORIES_COLLECTION,
|
|
22
|
+
} from "../types/subcategory.types";
|
|
23
|
+
import { BaseService } from "../../services/base.service";
|
|
24
|
+
import { CATEGORIES_COLLECTION } from "../types/category.types";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ID of the free-consultation subcategory that should be hidden from admin backoffice.
|
|
28
|
+
* This subcategory is used internally for free consultation procedures.
|
|
29
|
+
*/
|
|
30
|
+
const EXCLUDED_SUBCATEGORY_ID = 'free-consultation';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Servis za upravljanje podkategorijama procedura.
|
|
34
|
+
* Podkategorije su drugi nivo organizacije i pripadaju određenoj kategoriji.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const subcategoryService = new SubcategoryService();
|
|
38
|
+
*
|
|
39
|
+
* // Kreiranje nove podkategorije
|
|
40
|
+
* const subcategory = await subcategoryService.create(categoryId, {
|
|
41
|
+
* name: "Anti-Wrinkle",
|
|
42
|
+
* description: "Treatments targeting facial wrinkles"
|
|
43
|
+
* });
|
|
44
|
+
*/
|
|
45
|
+
export class SubcategoryService extends BaseService {
|
|
46
|
+
/**
|
|
47
|
+
* Filters out excluded subcategories from a list.
|
|
48
|
+
* @param subcategories - List of subcategories to filter
|
|
49
|
+
* @returns Filtered list without excluded subcategories
|
|
50
|
+
*/
|
|
51
|
+
private filterExcludedSubcategories(subcategories: Subcategory[]): Subcategory[] {
|
|
52
|
+
return subcategories.filter(sub => sub.id !== EXCLUDED_SUBCATEGORY_ID);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Vraća referencu na Firestore kolekciju podkategorija za određenu kategoriju
|
|
56
|
+
* @param categoryId - ID roditeljske kategorije
|
|
57
|
+
*/
|
|
58
|
+
private getSubcategoriesRef(categoryId: string) {
|
|
59
|
+
return collection(
|
|
60
|
+
this.db,
|
|
61
|
+
CATEGORIES_COLLECTION,
|
|
62
|
+
categoryId,
|
|
63
|
+
SUBCATEGORIES_COLLECTION
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Kreira novu podkategoriju u okviru kategorije
|
|
69
|
+
* @param categoryId - ID kategorije kojoj će pripadati nova podkategorija
|
|
70
|
+
* @param subcategory - Podaci za novu podkategoriju
|
|
71
|
+
* @returns Kreirana podkategorija sa generisanim ID-em
|
|
72
|
+
*/
|
|
73
|
+
async create(
|
|
74
|
+
categoryId: string,
|
|
75
|
+
subcategory: Omit<Subcategory, "id" | "createdAt" | "updatedAt">
|
|
76
|
+
) {
|
|
77
|
+
const now = new Date();
|
|
78
|
+
const newSubcategory: Omit<Subcategory, "id"> = {
|
|
79
|
+
...subcategory,
|
|
80
|
+
categoryId,
|
|
81
|
+
createdAt: now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
isActive: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const docRef = await addDoc(
|
|
87
|
+
this.getSubcategoriesRef(categoryId),
|
|
88
|
+
newSubcategory
|
|
89
|
+
);
|
|
90
|
+
return { id: docRef.id, ...newSubcategory };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns counts of subcategories for all categories.
|
|
95
|
+
* @param active - Whether to count active or inactive subcategories.
|
|
96
|
+
* @returns A record mapping category ID to subcategory count.
|
|
97
|
+
*/
|
|
98
|
+
async getSubcategoryCounts(active = true) {
|
|
99
|
+
const categoriesRef = collection(this.db, CATEGORIES_COLLECTION);
|
|
100
|
+
const categoriesSnapshot = await getDocs(categoriesRef);
|
|
101
|
+
const counts: Record<string, number> = {};
|
|
102
|
+
|
|
103
|
+
for (const categoryDoc of categoriesSnapshot.docs) {
|
|
104
|
+
const categoryId = categoryDoc.id;
|
|
105
|
+
const subcategoriesRef = this.getSubcategoriesRef(categoryId);
|
|
106
|
+
const q = query(subcategoriesRef, where("isActive", "==", active));
|
|
107
|
+
const snapshot = await getDocs(q);
|
|
108
|
+
// Filter out excluded subcategory and count
|
|
109
|
+
const filteredDocs = snapshot.docs.filter(doc => doc.id !== EXCLUDED_SUBCATEGORY_ID);
|
|
110
|
+
counts[categoryId] = filteredDocs.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return counts;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Vraća sve aktivne podkategorije za određenu kategoriju sa paginacijom
|
|
118
|
+
* @param categoryId - ID kategorije čije podkategorije tražimo
|
|
119
|
+
* @param options - Pagination options
|
|
120
|
+
* @returns Lista aktivnih podkategorija i poslednji vidljiv dokument
|
|
121
|
+
*/
|
|
122
|
+
async getAllByCategoryId(
|
|
123
|
+
categoryId: string,
|
|
124
|
+
options: {
|
|
125
|
+
active?: boolean;
|
|
126
|
+
limit?: number;
|
|
127
|
+
lastVisible?: DocumentData;
|
|
128
|
+
} = {}
|
|
129
|
+
) {
|
|
130
|
+
const { active = true, limit: queryLimit = 10, lastVisible } = options;
|
|
131
|
+
const constraints = [
|
|
132
|
+
where("isActive", "==", active),
|
|
133
|
+
orderBy("name"),
|
|
134
|
+
queryLimit ? limit(queryLimit) : undefined,
|
|
135
|
+
lastVisible ? startAfter(lastVisible) : undefined,
|
|
136
|
+
].filter((c): c is NonNullable<typeof c> => !!c);
|
|
137
|
+
|
|
138
|
+
const q = query(this.getSubcategoriesRef(categoryId), ...constraints);
|
|
139
|
+
|
|
140
|
+
const querySnapshot = await getDocs(q);
|
|
141
|
+
const subcategories = querySnapshot.docs.map(
|
|
142
|
+
(doc) =>
|
|
143
|
+
({
|
|
144
|
+
id: doc.id,
|
|
145
|
+
...doc.data(),
|
|
146
|
+
} as Subcategory)
|
|
147
|
+
);
|
|
148
|
+
const filteredSubcategories = this.filterExcludedSubcategories(subcategories);
|
|
149
|
+
const newLastVisible = querySnapshot.docs[querySnapshot.docs.length - 1];
|
|
150
|
+
return { subcategories: filteredSubcategories, lastVisible: newLastVisible };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Vraća sve podkategorije sa paginacijom koristeći collection group query.
|
|
155
|
+
* NOTE: This query requires a composite index in Firestore on the 'subcategories' collection group.
|
|
156
|
+
* The index should be on 'isActive' (ascending) and 'name' (ascending).
|
|
157
|
+
* Firestore will provide a link to create this index in the console error if it's missing.
|
|
158
|
+
* @param options - Pagination options
|
|
159
|
+
* @returns Lista podkategorija i poslednji vidljiv dokument
|
|
160
|
+
*/
|
|
161
|
+
async getAll(
|
|
162
|
+
options: {
|
|
163
|
+
active?: boolean;
|
|
164
|
+
limit?: number;
|
|
165
|
+
lastVisible?: DocumentData;
|
|
166
|
+
} = {}
|
|
167
|
+
) {
|
|
168
|
+
const { active = true, limit: queryLimit = 10, lastVisible } = options;
|
|
169
|
+
const constraints = [
|
|
170
|
+
where("isActive", "==", active),
|
|
171
|
+
orderBy("name"),
|
|
172
|
+
queryLimit ? limit(queryLimit) : undefined,
|
|
173
|
+
lastVisible ? startAfter(lastVisible) : undefined,
|
|
174
|
+
].filter((c): c is NonNullable<typeof c> => !!c);
|
|
175
|
+
|
|
176
|
+
const q = query(
|
|
177
|
+
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
178
|
+
...constraints
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const querySnapshot = await getDocs(q);
|
|
182
|
+
const subcategories = querySnapshot.docs.map(
|
|
183
|
+
(doc) =>
|
|
184
|
+
({
|
|
185
|
+
id: doc.id,
|
|
186
|
+
...doc.data(),
|
|
187
|
+
} as Subcategory)
|
|
188
|
+
);
|
|
189
|
+
const filteredSubcategories = this.filterExcludedSubcategories(subcategories);
|
|
190
|
+
const newLastVisible = querySnapshot.docs[querySnapshot.docs.length - 1];
|
|
191
|
+
return { subcategories: filteredSubcategories, lastVisible: newLastVisible };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Vraća sve subkategorije za određenu kategoriju za potrebe filtera (bez paginacije)
|
|
196
|
+
* @param categoryId - ID kategorije čije subkategorije tražimo
|
|
197
|
+
* @returns Lista svih aktivnih subkategorija
|
|
198
|
+
*/
|
|
199
|
+
async getAllForFilterByCategoryId(categoryId: string) {
|
|
200
|
+
const q = query(
|
|
201
|
+
this.getSubcategoriesRef(categoryId),
|
|
202
|
+
where("isActive", "==", true)
|
|
203
|
+
);
|
|
204
|
+
const querySnapshot = await getDocs(q);
|
|
205
|
+
const subcategories = querySnapshot.docs.map(
|
|
206
|
+
(doc) =>
|
|
207
|
+
({
|
|
208
|
+
id: doc.id,
|
|
209
|
+
...doc.data(),
|
|
210
|
+
} as Subcategory)
|
|
211
|
+
);
|
|
212
|
+
return this.filterExcludedSubcategories(subcategories);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Vraća sve subkategorije za potrebe filtera (bez paginacije)
|
|
217
|
+
* @returns Lista svih aktivnih subkategorija
|
|
218
|
+
*/
|
|
219
|
+
async getAllForFilter() {
|
|
220
|
+
const q = query(
|
|
221
|
+
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
222
|
+
where("isActive", "==", true)
|
|
223
|
+
);
|
|
224
|
+
const querySnapshot = await getDocs(q);
|
|
225
|
+
const subcategories = querySnapshot.docs.map(
|
|
226
|
+
(doc) =>
|
|
227
|
+
({
|
|
228
|
+
id: doc.id,
|
|
229
|
+
...doc.data(),
|
|
230
|
+
} as Subcategory)
|
|
231
|
+
);
|
|
232
|
+
return this.filterExcludedSubcategories(subcategories);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Ažurira postojeću podkategoriju
|
|
237
|
+
* @param categoryId - ID kategorije kojoj pripada podkategorija
|
|
238
|
+
* @param subcategoryId - ID podkategorije koja se ažurira
|
|
239
|
+
* @param subcategory - Novi podaci za podkategoriju
|
|
240
|
+
* @returns Ažurirana podkategorija
|
|
241
|
+
*/
|
|
242
|
+
async update(
|
|
243
|
+
categoryId: string,
|
|
244
|
+
subcategoryId: string,
|
|
245
|
+
subcategory: Partial<Omit<Subcategory, "id" | "createdAt">>
|
|
246
|
+
) {
|
|
247
|
+
const newCategoryId = subcategory.categoryId;
|
|
248
|
+
|
|
249
|
+
if (newCategoryId && newCategoryId !== categoryId) {
|
|
250
|
+
// Category has changed, move the document
|
|
251
|
+
const oldDocRef = doc(
|
|
252
|
+
this.getSubcategoriesRef(categoryId),
|
|
253
|
+
subcategoryId
|
|
254
|
+
);
|
|
255
|
+
const docSnap = await getDoc(oldDocRef);
|
|
256
|
+
|
|
257
|
+
if (!docSnap.exists()) {
|
|
258
|
+
throw new Error("Subcategory to update does not exist.");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const existingData = docSnap.data();
|
|
262
|
+
const newData: Omit<Subcategory, "id"> = {
|
|
263
|
+
...(existingData as Omit<
|
|
264
|
+
Subcategory,
|
|
265
|
+
"id" | "createdAt" | "updatedAt"
|
|
266
|
+
>),
|
|
267
|
+
...subcategory,
|
|
268
|
+
categoryId: newCategoryId, // Ensure categoryId is updated
|
|
269
|
+
createdAt: existingData.createdAt, // Preserve original creation date
|
|
270
|
+
updatedAt: new Date(),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const newDocRef = doc(
|
|
274
|
+
this.getSubcategoriesRef(newCategoryId),
|
|
275
|
+
subcategoryId
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await setDoc(newDocRef, newData);
|
|
279
|
+
await deleteDoc(oldDocRef);
|
|
280
|
+
|
|
281
|
+
return { id: subcategoryId, ...newData };
|
|
282
|
+
} else {
|
|
283
|
+
// Category has not changed, just update the document
|
|
284
|
+
const updateData = {
|
|
285
|
+
...subcategory,
|
|
286
|
+
updatedAt: new Date(),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
290
|
+
await updateDoc(docRef, updateData);
|
|
291
|
+
return this.getById(categoryId, subcategoryId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Soft delete podkategorije (postavlja isActive na false)
|
|
297
|
+
* @param categoryId - ID kategorije kojoj pripada podkategorija
|
|
298
|
+
* @param subcategoryId - ID podkategorije koja se briše
|
|
299
|
+
*/
|
|
300
|
+
async delete(categoryId: string, subcategoryId: string) {
|
|
301
|
+
await this.update(categoryId, subcategoryId, { isActive: false });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reactivates a subcategory by setting its isActive flag to true.
|
|
306
|
+
* @param categoryId - The ID of the category to which the subcategory belongs.
|
|
307
|
+
* @param subcategoryId - The ID of the subcategory to reactivate.
|
|
308
|
+
*/
|
|
309
|
+
async reactivate(categoryId: string, subcategoryId: string) {
|
|
310
|
+
await this.update(categoryId, subcategoryId, { isActive: true });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Vraća podkategoriju po ID-u
|
|
315
|
+
* @param categoryId - ID kategorije kojoj pripada podkategorija
|
|
316
|
+
* @param subcategoryId - ID tražene podkategorije
|
|
317
|
+
* @returns Podkategorija ili null ako ne postoji
|
|
318
|
+
*/
|
|
319
|
+
async getById(categoryId: string, subcategoryId: string) {
|
|
320
|
+
// Prevent access to excluded subcategory
|
|
321
|
+
if (subcategoryId === EXCLUDED_SUBCATEGORY_ID) return null;
|
|
322
|
+
|
|
323
|
+
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
324
|
+
const docSnap = await getDoc(docRef);
|
|
325
|
+
if (!docSnap.exists()) return null;
|
|
326
|
+
return {
|
|
327
|
+
id: docSnap.id,
|
|
328
|
+
...docSnap.data(),
|
|
329
|
+
} as Subcategory;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Internal method to get subcategory by ID without filtering.
|
|
334
|
+
* Used internally for consultation procedures.
|
|
335
|
+
* @param categoryId - ID of the category
|
|
336
|
+
* @param subcategoryId - ID of the subcategory to get
|
|
337
|
+
* @returns Subcategory or null if not found
|
|
338
|
+
*/
|
|
339
|
+
async getByIdInternal(categoryId: string, subcategoryId: string): Promise<Subcategory | null> {
|
|
340
|
+
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
341
|
+
const docSnap = await getDoc(docRef);
|
|
342
|
+
if (!docSnap.exists()) return null;
|
|
343
|
+
return {
|
|
344
|
+
id: docSnap.id,
|
|
345
|
+
...docSnap.data(),
|
|
346
|
+
} as Subcategory;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Finds a subcategory by exact name match within a specific category.
|
|
351
|
+
* Used for CSV import matching.
|
|
352
|
+
* @param name - Exact name of the subcategory to find
|
|
353
|
+
* @param categoryId - ID of the category to search within
|
|
354
|
+
* @returns Subcategory if found, null otherwise
|
|
355
|
+
*/
|
|
356
|
+
async findByNameAndCategory(name: string, categoryId: string): Promise<Subcategory | null> {
|
|
357
|
+
const q = query(
|
|
358
|
+
this.getSubcategoriesRef(categoryId),
|
|
359
|
+
where('name', '==', name),
|
|
360
|
+
where('isActive', '==', true),
|
|
361
|
+
);
|
|
362
|
+
const querySnapshot = await getDocs(q);
|
|
363
|
+
if (querySnapshot.empty) return null;
|
|
364
|
+
const doc = querySnapshot.docs[0];
|
|
365
|
+
// Exclude free-consultation subcategory
|
|
366
|
+
if (doc.id === EXCLUDED_SUBCATEGORY_ID) return null;
|
|
367
|
+
return {
|
|
368
|
+
id: doc.id,
|
|
369
|
+
...doc.data(),
|
|
370
|
+
} as Subcategory;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Exports subcategories to CSV string, suitable for Excel/Sheets.
|
|
375
|
+
* Includes headers and optional UTF-8 BOM.
|
|
376
|
+
* By default exports only active subcategories (set includeInactive to true to export all).
|
|
377
|
+
*/
|
|
378
|
+
async exportToCsv(options?: {
|
|
379
|
+
includeInactive?: boolean;
|
|
380
|
+
includeBom?: boolean;
|
|
381
|
+
}): Promise<string> {
|
|
382
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
383
|
+
const includeBom = options?.includeBom ?? true;
|
|
384
|
+
|
|
385
|
+
const headers = [
|
|
386
|
+
"id",
|
|
387
|
+
"name",
|
|
388
|
+
"categoryId",
|
|
389
|
+
"description",
|
|
390
|
+
"isActive",
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
const rows: string[] = [];
|
|
394
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
395
|
+
|
|
396
|
+
const PAGE_SIZE = 1000;
|
|
397
|
+
let cursor: any | undefined;
|
|
398
|
+
|
|
399
|
+
// Build base constraints
|
|
400
|
+
const constraints: any[] = [];
|
|
401
|
+
if (!includeInactive) {
|
|
402
|
+
constraints.push(where("isActive", "==", true));
|
|
403
|
+
}
|
|
404
|
+
constraints.push(orderBy("name"));
|
|
405
|
+
|
|
406
|
+
// Page through all results using collectionGroup
|
|
407
|
+
// eslint-disable-next-line no-constant-condition
|
|
408
|
+
while (true) {
|
|
409
|
+
const queryConstraints: any[] = [...constraints, limit(PAGE_SIZE)];
|
|
410
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
411
|
+
|
|
412
|
+
const q = query(
|
|
413
|
+
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
414
|
+
...queryConstraints
|
|
415
|
+
);
|
|
416
|
+
const snapshot = await getDocs(q);
|
|
417
|
+
if (snapshot.empty) break;
|
|
418
|
+
|
|
419
|
+
for (const d of snapshot.docs) {
|
|
420
|
+
// Exclude free-consultation subcategory from CSV export
|
|
421
|
+
if (d.id === EXCLUDED_SUBCATEGORY_ID) continue;
|
|
422
|
+
const subcategory = ({ id: d.id, ...d.data() } as unknown) as Subcategory;
|
|
423
|
+
rows.push(this.subcategoryToCsvRow(subcategory));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
427
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const csvBody = rows.join("\r\n");
|
|
431
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private subcategoryToCsvRow(subcategory: Subcategory): string {
|
|
435
|
+
const values = [
|
|
436
|
+
subcategory.id ?? "",
|
|
437
|
+
subcategory.name ?? "",
|
|
438
|
+
subcategory.categoryId ?? "",
|
|
439
|
+
subcategory.description ?? "",
|
|
440
|
+
String(subcategory.isActive ?? ""),
|
|
441
|
+
];
|
|
442
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private formatDateIso(value: any): string {
|
|
446
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
447
|
+
if (value instanceof Date) return value.toISOString();
|
|
448
|
+
if (value && typeof value.toDate === "function") {
|
|
449
|
+
const d = value.toDate();
|
|
450
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
451
|
+
}
|
|
452
|
+
return String(value ?? "");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private formatCsvValue(value: any): string {
|
|
456
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
457
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
458
|
+
const escaped = str.replace(/"/g, '""');
|
|
459
|
+
return `"${escaped}"`;
|
|
460
|
+
}
|
|
461
|
+
}
|