@smarterplan/ngx-smarterplan-core 1.4.14 → 1.4.15

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.
@@ -1207,6 +1207,22 @@ class MissionService {
1207
1207
  async getMissionsForSpace(spaceID) {
1208
1208
  return this.API.__proto__.MissionsBySpace(spaceID).then((missions) => missions.items);
1209
1209
  }
1210
+ /**
1211
+ * Same as getMissionsForSpace(), but follows `nextToken` until the connection is
1212
+ * exhausted instead of returning only the first page.
1213
+ */
1214
+ async getAllMissionsForSpace(spaceID) {
1215
+ const all = [];
1216
+ let nextToken = null;
1217
+ do {
1218
+ const page = await this.API.__proto__.MissionsBySpace(spaceID, undefined, undefined, 1000, nextToken);
1219
+ if (page?.items?.length > 0) {
1220
+ all.push(...page.items.filter((mis) => !!mis && !mis.deletedAt));
1221
+ }
1222
+ nextToken = page?.nextToken ?? null;
1223
+ } while (nextToken);
1224
+ return all;
1225
+ }
1210
1226
  activateMission(mis) {
1211
1227
  if (mis &&
1212
1228
  (!mis.groups || !mis.groups.includes(ProfileStatus.ACTIVE))) {
@@ -8799,7 +8815,9 @@ class EquipmentService extends BaseObjectService {
8799
8815
  const subEquips = [];
8800
8816
  await Promise.all(equip.subequipmentsIDs.map(async (subId) => {
8801
8817
  const subEquip = await this.API.GetEquipment(subId);
8802
- subEquips.push(subEquip);
8818
+ if (subEquip) {
8819
+ subEquips.push(subEquip);
8820
+ }
8803
8821
  }));
8804
8822
  equip.subEquipments = subEquips;
8805
8823
  }
@@ -11781,12 +11799,6 @@ class FeatureService extends BaseObjectService {
11781
11799
  /**
11782
11800
  * Returns EVERY feature of a space, following `nextToken` until the index is
11783
11801
  * exhausted, with no user/archive filtering at all.
11784
- *
11785
- * `getFeaturesBySpaceWithoutFiltering()` reads a single page (the AppSync default
11786
- * caps it at 100 items) and calls `isArchived()`, which dereferences the current
11787
- * user's mission ON THAT SPACE — so it both truncates large spaces and throws for a
11788
- * space the operator has no mission on. Duplication has to see the raw, complete
11789
- * list, hence this separate accessor rather than a change of the existing one.
11790
11802
  */
11791
11803
  async getAllFeaturesBySpaceRaw(spaceID) {
11792
11804
  if (!spaceID) {
@@ -14252,6 +14264,100 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
14252
14264
  args: ['currentAPIService']
14253
14265
  }] }, { type: BaseUserService }, { type: NavigatorService }, { type: ZoneChangeService }, { type: FilterService }] });
14254
14266
 
14267
+ class DomainService {
14268
+ API; //AWS
14269
+ constructor(apiInjected) {
14270
+ this.API = apiInjected;
14271
+ }
14272
+ async getGenericDomainsAndForOrganisation(organisationID) {
14273
+ return this.API.__proto__.ListDomains().then((domains) => {
14274
+ return domains.items.filter((dom) => {
14275
+ return (dom.organisationID === organisationID || !dom.organisationID);
14276
+ });
14277
+ });
14278
+ }
14279
+ /**
14280
+ * Same as getGenericDomainsAndForOrganisation(), but drains every page instead of only
14281
+ * the first. `ListDomains` has no per-organisation index — every call already scans the
14282
+ * WHOLE table across every organisation before filtering client-side — so a catalogue
14283
+ * past the first page was silently invisible to callers that need the complete list
14284
+ * (e.g. matching an organisation-specific domain by name across a space-to-space copy).
14285
+ */
14286
+ async getAllGenericDomainsAndForOrganisation(organisationID) {
14287
+ const all = [];
14288
+ let nextToken = null;
14289
+ do {
14290
+ const page = await this.API.__proto__.ListDomains(undefined, 1000, nextToken);
14291
+ if (page?.items?.length > 0) {
14292
+ all.push(...page.items.filter((dom) => !!dom));
14293
+ }
14294
+ nextToken = page?.nextToken ?? null;
14295
+ } while (nextToken);
14296
+ return all.filter(dom => dom.organisationID === organisationID || !dom.organisationID);
14297
+ }
14298
+ async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
14299
+ // TODO: Change this (UUID can change depending on DB)
14300
+ if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
14301
+ // I am SmarterPlan, i can see all
14302
+ return this.API.__proto__.ListDomains().then((response) => response.items);
14303
+ }
14304
+ const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
14305
+ if (!orderedOrgID || orgID === orderedOrgID) {
14306
+ return currentOrgDomains;
14307
+ }
14308
+ const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
14309
+ const merged = [...currentOrgDomains, ...orderedOrgDomains];
14310
+ return filterUniqueArrayByID(merged);
14311
+ }
14312
+ async getDomainsForOrgExcludingTypes(types, organisationID) {
14313
+ return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
14314
+ return domains.filter((dom) => !types.includes(dom.type));
14315
+ });
14316
+ }
14317
+ async getDomain(id) {
14318
+ return this.API.__proto__.GetDomain(id);
14319
+ }
14320
+ async updateDomain(input) {
14321
+ return this.API.__proto__.UpdateDomain(input);
14322
+ }
14323
+ async deleteDomain(id) {
14324
+ return this.API.__proto__.DeleteDomain({ id });
14325
+ }
14326
+ async getRootDomains(orgID) {
14327
+ const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
14328
+ const root = allDomains.filter((dom) => !dom.parentID);
14329
+ return root.sort((a, b) => a.name.localeCompare(b.name));
14330
+ }
14331
+ async createDomain(input) {
14332
+ return this.API.__proto__.CreateDomain(input);
14333
+ }
14334
+ async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
14335
+ return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
14336
+ return domains.filter((dom) => dom.type === type);
14337
+ });
14338
+ }
14339
+ async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
14340
+ return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
14341
+ return domains.filter((dom) => dom.type === type && !dom.parentID);
14342
+ });
14343
+ }
14344
+ async getDomainsForMissionsForOrg(orgID) {
14345
+ const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
14346
+ return domains.filter((dom) => !dom.parentID);
14347
+ }
14348
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
14349
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
14350
+ }
14351
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
14352
+ type: Injectable,
14353
+ args: [{
14354
+ providedIn: "root",
14355
+ }]
14356
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
14357
+ type: Inject,
14358
+ args: ['currentAPIService']
14359
+ }] }] });
14360
+
14255
14361
  /** Safely extracts a readable message from an unknown catch value, including Amplify's
14256
14362
  * GraphQL error shape ({ data, errors: [{ errorType, message }] }). */
14257
14363
  function toErrorMessage(err) {
@@ -14304,7 +14410,9 @@ class MatterportTagCopyService {
14304
14410
  measurementService;
14305
14411
  commentService;
14306
14412
  hashtagService;
14307
- spaceService;
14413
+ missionService;
14414
+ operationService;
14415
+ domainService;
14308
14416
  translate;
14309
14417
  // Emits progress updates during copyTagsFromVisit(); null when idle
14310
14418
  copyProgress$ = new BehaviorSubject(null);
@@ -14312,7 +14420,8 @@ class MatterportTagCopyService {
14312
14420
  copyLog$ = new BehaviorSubject([]);
14313
14421
  spModule;
14314
14422
  log = [];
14315
- errorSeq = 0;
14423
+ /** Unique-id sequence shared by every permanent (non-step) log line — errors and healed-item notices alike. */
14424
+ logSeq = 0;
14316
14425
  // ---- Per-run remapping state (reset by resetRunState() at the start of every copy) ----
14317
14426
  /** source element id -> id of its copy in the target space. */
14318
14427
  elementIds = {};
@@ -14335,12 +14444,36 @@ class MatterportTagCopyService {
14335
14444
  /** source ids of the elements whose POIs were already created by an element phase.
14336
14445
  * Guards the standalone POI phase against creating a second, impoverished copy. */
14337
14446
  handledElementIds = new Set();
14338
- /** true when source and target spaces belong to the same organisation. Domains are
14339
- * organisation-scoped (no spaceID), so domainID survives the copy only in that case. */
14340
- sameOrganisation = false;
14447
+ // Comment authorship: `Comment.ownerID` is a foreign key to a Mission, and a Mission is
14448
+ // scoped to ONE space (userID + spaceID). A source comment's ownerID therefore points at
14449
+ // nobody in the target space, so it has to be translated to the Mission of the SAME user
14450
+ // there — see resolveCommentOwnerID().
14451
+ /** source Mission id -> its userID. */
14452
+ sourceMissionUserIds = new Map();
14453
+ /** userID -> that user's Mission id in the TARGET space (only set when they have one). */
14454
+ targetMissionIdsByUserId = new Map();
14455
+ // Domain (equipment/ticket/feature/POI category) resolution mirrors the exact rule the
14456
+ // edit forms use to populate their own category dropdown —
14457
+ // DomainService.getDomainsForOrgForType(type, mission.organisationID,
14458
+ // mission.orderedOrganisationID) — keyed to the OPERATOR's own Mission on each space,
14459
+ // NOT to the Space's own organisationID. Two spaces in different organisations can
14460
+ // still share a domain through that Mission link (an operator whose mission on the
14461
+ // target space carries an `orderedOrganisationID` matching the domain's own
14462
+ // organisationID sees — and can reuse — it directly). See resolveDomainID().
14463
+ /** id -> Domain, for every domain visible from EITHER space's operator mission (its
14464
+ * organisationID, its orderedOrganisationID, or generic/no-organisation domains) — a
14465
+ * superset covering whatever a source object's domainID could point at. */
14466
+ domainsById = new Map();
14467
+ /** organisationIDs the TARGET operator's mission can see domains from. A domain whose
14468
+ * own organisationID is in this set (or has none at all) is already directly
14469
+ * selectable in the target space's own dropdown, so its id is reused unchanged. */
14470
+ targetVisibleDomainOrgIds = new Set();
14471
+ /** `${type}::${normalised name}` -> that domain's id, among domains visible to the
14472
+ * TARGET operator — used to find an equivalent when the exact id isn't reusable. */
14473
+ targetDomainIdsByTypeAndName = new Map();
14341
14474
  /** Target space id of the run in progress — used to re-home copied tag icons. */
14342
14475
  targetSpaceId = null;
14343
- constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, spaceService, translate) {
14476
+ constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, missionService, operationService, domainService, translate) {
14344
14477
  this.navigationService = navigationService;
14345
14478
  this.userService = userService;
14346
14479
  this.poiService = poiService;
@@ -14351,7 +14484,9 @@ class MatterportTagCopyService {
14351
14484
  this.measurementService = measurementService;
14352
14485
  this.commentService = commentService;
14353
14486
  this.hashtagService = hashtagService;
14354
- this.spaceService = spaceService;
14487
+ this.missionService = missionService;
14488
+ this.operationService = operationService;
14489
+ this.domainService = domainService;
14355
14490
  this.translate = translate;
14356
14491
  this.spModule = userService.getSpModule();
14357
14492
  }
@@ -14360,6 +14495,7 @@ class MatterportTagCopyService {
14360
14495
  //
14361
14496
  async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
14362
14497
  let errorCount = 0;
14498
+ let healedCount = 0;
14363
14499
  this.resetLog();
14364
14500
  this.resetRunState(targetSpaceId);
14365
14501
  try {
@@ -14369,7 +14505,7 @@ class MatterportTagCopyService {
14369
14505
  phase: 'collecting',
14370
14506
  processedInPhase: 0, totalInPhase: 0,
14371
14507
  processedOverall: 0, totalOverall: 0,
14372
- percent: 0, errorCount: 0
14508
+ percent: 0, errorCount: 0, healedCount: 0
14373
14509
  });
14374
14510
  const targetMission = this.userService.currentMission(targetSpaceId);
14375
14511
  const targetMissionId = targetMission ? targetMission.id : null;
@@ -14379,9 +14515,6 @@ class MatterportTagCopyService {
14379
14515
  refSource: transform.refSource,
14380
14516
  refTarget: transform.refTarget
14381
14517
  });
14382
- // Domains are organisation-scoped, hashtags are space-scoped: both need to know
14383
- // whether we stay inside the same organisation before anything is remapped.
14384
- this.sameOrganisation = await this.spacesShareOrganisation(sourceSpaceId, targetSpaceId);
14385
14518
  const sourceSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(sourceSpaceId);
14386
14519
  const targetSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(targetSpaceId);
14387
14520
  this.setStepProgress('collecting', 'collecting', 30);
@@ -14422,6 +14555,14 @@ class MatterportTagCopyService {
14422
14555
  // Keywords: index both vocabularies now (read-only). The keywords the target space
14423
14556
  // is missing are created later, POI by POI, only for those actually used.
14424
14557
  await this.loadHashtagIndex(sourceSpaceId, targetSpaceId);
14558
+ // Missions: index both spaces now (read-only), so copied comments can credit
14559
+ // their real author instead of whoever is running the copy.
14560
+ await this.loadMissionIndex(sourceSpaceId, targetSpaceId);
14561
+ // Domains: index every domain either operator's mission can see (read-only), so
14562
+ // a category survives a copy across two DIFFERENT organisations whenever it
14563
+ // genuinely would be selectable in the target space's own dropdown — see
14564
+ // loadDomainIndex() for why this is keyed to Mission, not to the Space itself.
14565
+ await this.loadDomainIndex(sourceSpaceId, targetSpaceId);
14425
14566
  if (this.spModule == SpModule.IMMO) {
14426
14567
  sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
14427
14568
  targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
@@ -14479,7 +14620,7 @@ class MatterportTagCopyService {
14479
14620
  processedOverall,
14480
14621
  totalOverall,
14481
14622
  percent: totalOverall > 0 ? Math.round((processedOverall / totalOverall) * 100) : 0,
14482
- errorCount
14623
+ errorCount, healedCount
14483
14624
  });
14484
14625
  };
14485
14626
  if (this.spModule == SpModule.IMMO) {
@@ -14487,8 +14628,8 @@ class MatterportTagCopyService {
14487
14628
  for (let i = 0; i < sourceTickets.length; i++) {
14488
14629
  const sourceObj = sourceTickets[i];
14489
14630
  try {
14490
- const existsInTarget = this.matchesExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
14491
- if (!existsInTarget) {
14631
+ const existingMatch = this.findExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
14632
+ if (!existingMatch) {
14492
14633
  const rawSource = sourceObj;
14493
14634
  const ticketInput = {
14494
14635
  title: rawSource.title,
@@ -14509,8 +14650,9 @@ class MatterportTagCopyService {
14509
14650
  spaceID: targetSpaceId,
14510
14651
  // Remapped in the second pass below, once every ticket exists.
14511
14652
  parentID: null,
14512
- // Domains are organisation-scoped, like for equipments and features.
14513
- domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
14653
+ // Reused unchanged in the same organisation, matched by name
14654
+ // across two — see resolveDomainID().
14655
+ domainID: this.resolveDomainID(rawSource.domainID),
14514
14656
  ownerMissionID: targetMissionId
14515
14657
  };
14516
14658
  const createdObj = await this.ticketService.createTicket(ticketInput);
@@ -14531,7 +14673,7 @@ class MatterportTagCopyService {
14531
14673
  }
14532
14674
  for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14533
14675
  try {
14534
- const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
14676
+ const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId);
14535
14677
  if (created?.id && sourceComment.id) {
14536
14678
  this.commentIds.set(sourceComment.id, created.id);
14537
14679
  }
@@ -14549,6 +14691,18 @@ class MatterportTagCopyService {
14549
14691
  await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14550
14692
  }
14551
14693
  }
14694
+ else {
14695
+ // Same reasoning as the EQUIPMENTS phase (see the comment there):
14696
+ // a ticket matching this one already exists in the target space,
14697
+ // most likely from an earlier, incomplete run. Fill in whatever
14698
+ // identity field it is missing rather than leaving it untouched.
14699
+ elementIds[sourceObj.id] = existingMatch.id;
14700
+ const healedFields = await this.healTicketIdentity(sourceObj, existingMatch);
14701
+ if (healedFields.length > 0) {
14702
+ healedCount++;
14703
+ this.logHealedItem('ticketHealed', sourceObj?.title || sourceObj?.id, healedFields);
14704
+ }
14705
+ }
14552
14706
  }
14553
14707
  catch (itemError) {
14554
14708
  console.error(`Error copying ticket "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
@@ -14593,8 +14747,8 @@ class MatterportTagCopyService {
14593
14747
  try {
14594
14748
  // Equipment has no `title`/`description`: it identifies itself by name, and by
14595
14749
  // serial number when one is recorded.
14596
- const existsInTarget = this.matchesExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
14597
- if (!existsInTarget) {
14750
+ const existingMatch = this.findExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
14751
+ if (!existingMatch) {
14598
14752
  const rawSource = sourceObj;
14599
14753
  let copiedPhotoKey = null;
14600
14754
  if (rawSource.photo) {
@@ -14602,6 +14756,12 @@ class MatterportTagCopyService {
14602
14756
  }
14603
14757
  const equipmentInput = {
14604
14758
  name: rawSource.name,
14759
+ // Unlike every other model this file copies, Equipment declares
14760
+ // `createdAt` as a plain client-settable field rather than an
14761
+ // AppSync-managed timestamp (see schema.graphql) — so, uniquely
14762
+ // here, the original commissioning date can actually be kept
14763
+ // instead of the copy silently getting today's date.
14764
+ createdAt: rawSource.createdAt ?? null,
14605
14765
  annotation: rawSource.annotation ?? null,
14606
14766
  status: rawSource.status,
14607
14767
  // Full identity of the equipment — all of these were dropped
@@ -14618,8 +14778,9 @@ class MatterportTagCopyService {
14618
14778
  // while hiding the item from no one — reset it, as for features.
14619
14779
  archivedForMissionID: null,
14620
14780
  spaceID: targetSpaceId,
14621
- // Domains are organisation-scoped, like for features and POIs.
14622
- domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
14781
+ // Reused unchanged in the same organisation, matched by name
14782
+ // across two — see resolveDomainID().
14783
+ domainID: this.resolveDomainID(rawSource.domainID),
14623
14784
  photo: copiedPhotoKey,
14624
14785
  // `subequipmentsIDs` is deliberately absent: it is remapped to the
14625
14786
  // copied equipments in the second pass, once they all exist.
@@ -14656,6 +14817,30 @@ class MatterportTagCopyService {
14656
14817
  await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14657
14818
  }
14658
14819
  }
14820
+ else {
14821
+ // An equipment matching this one already exists in the target space —
14822
+ // most often because a PREVIOUS run of this copy already created it.
14823
+ // That earlier run may have been interrupted, or hit a bug that has
14824
+ // since been fixed (missing family/manufacturer/domainID/... were once
14825
+ // dropped entirely — see the comment above), leaving an equipment with
14826
+ // its identity fields empty forever: re-running the copy used to skip
14827
+ // it outright, since it already "exists", so the gap could never heal.
14828
+ //
14829
+ // Re-running the tool must be able to repair that: any identity field
14830
+ // the existing target equipment is missing, and the source has, is
14831
+ // filled in. Fields the target already carries a value for are left
14832
+ // untouched, so a manual edit made in the target space is never
14833
+ // overwritten by an older or different source value. Notes and POIs are
14834
+ // NOT re-created here: unlike scalar fields they cannot be "missing vs.
14835
+ // present" checked the same way, and re-adding them on every re-run of
14836
+ // an already-healthy copy would duplicate them.
14837
+ elementIds[sourceObj.id] = existingMatch.id;
14838
+ const healedFields = await this.healEquipmentIdentity(sourceObj, existingMatch);
14839
+ if (healedFields.length > 0) {
14840
+ healedCount++;
14841
+ this.logHealedItem('equipmentHealed', sourceObj?.name || sourceObj?.id, healedFields);
14842
+ }
14843
+ }
14659
14844
  }
14660
14845
  catch (itemError) {
14661
14846
  console.error(`Error copying equipment "${sourceObj?.name}" (id: ${sourceObj?.id}):`, itemError);
@@ -14692,6 +14877,50 @@ class MatterportTagCopyService {
14692
14877
  }
14693
14878
  }
14694
14879
  }
14880
+ // OPERATIONS — the Ticket<->Equipment join (drives "Tickets actuels" on the
14881
+ // equipment page and the linked-equipment list on the ticket page). Both the
14882
+ // TICKETS and EQUIPMENTS phases above copy the record itself, but neither one
14883
+ // touches this join table, so every link between a copied ticket and a copied
14884
+ // equipment was silently dropped: both sides existed in the target space, but
14885
+ // pointing at nobody.
14886
+ //
14887
+ // Runs after both phases, over every SOURCE ticket that ended up mapped in
14888
+ // the target space (freshly created OR healed — elementIds covers both), so a
14889
+ // re-run over an already-copied pair of spaces can still pick up links that
14890
+ // an earlier, incomplete run never created.
14891
+ for (const sourceTicket of sourceTickets) {
14892
+ const newTicketId = elementIds[sourceTicket.id];
14893
+ if (!newTicketId) {
14894
+ continue;
14895
+ }
14896
+ try {
14897
+ const sourceOps = await this.operationService.getOperationsByTicketID(sourceTicket.id);
14898
+ if (sourceOps.length === 0) {
14899
+ continue;
14900
+ }
14901
+ // Read what the target ticket is already linked to, so a re-run never
14902
+ // creates a duplicate Operation for a link that already exists.
14903
+ const targetOps = await this.operationService.getOperationsByTicketID(newTicketId);
14904
+ const linkedEquipmentIds = new Set(targetOps.map(op => op.equipmentID));
14905
+ for (const sourceOp of sourceOps) {
14906
+ const newEquipmentId = sourceOp.equipmentID ? elementIds[sourceOp.equipmentID] : null;
14907
+ // No copied counterpart for the source equipment (not collected,
14908
+ // outside the current filter, ...) — same rule as everywhere else
14909
+ // in this file: drop the link rather than point it at nothing or
14910
+ // at the source space's equipment.
14911
+ if (!newEquipmentId || linkedEquipmentIds.has(newEquipmentId)) {
14912
+ continue;
14913
+ }
14914
+ await this.operationService.createOperation({ ticketID: newTicketId, equipmentID: newEquipmentId });
14915
+ linkedEquipmentIds.add(newEquipmentId);
14916
+ }
14917
+ }
14918
+ catch (opError) {
14919
+ console.error(`Error copying ticket-equipment links for "${sourceTicket?.title}":`, opError);
14920
+ errorCount++;
14921
+ this.logCopyItemError(opError, 'errorOperation', sourceTicket?.title || sourceTicket?.id);
14922
+ }
14923
+ }
14695
14924
  }
14696
14925
  // OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
14697
14926
  for (let i = 0; i < sourceObjects3D.length; i++) {
@@ -14814,8 +15043,8 @@ class MatterportTagCopyService {
14814
15043
  for (let i = 0; i < sourceFeatures.length; i++) {
14815
15044
  const sourceObj = sourceFeatures[i];
14816
15045
  try {
14817
- const existsInTarget = this.matchesExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
14818
- if (!existsInTarget) {
15046
+ const existingMatch = this.findExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
15047
+ if (!existingMatch) {
14819
15048
  const rawSource = sourceObj;
14820
15049
  // Resolved just before the write: a keyword is only created in the target
14821
15050
  // space when the POI carrying it is about to be created.
@@ -14841,9 +15070,9 @@ class MatterportTagCopyService {
14841
15070
  // equivalent hashtags of the target space (created on demand).
14842
15071
  hashtags: resolvedHashtags,
14843
15072
  description: rawSource.description || null,
14844
- // Domains are organisation-scoped, so the category survives a copy
14845
- // inside one organisation and only has to be dropped across two.
14846
- domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
15073
+ // Reused unchanged in the same organisation, matched by name
15074
+ // across two — see resolveDomainID().
15075
+ domainID: this.resolveDomainID(rawSource.domainID),
14847
15076
  spaceID: targetSpaceId,
14848
15077
  ownerMissionID: targetMissionId
14849
15078
  };
@@ -14868,7 +15097,7 @@ class MatterportTagCopyService {
14868
15097
  }
14869
15098
  for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14870
15099
  try {
14871
- const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
15100
+ const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId);
14872
15101
  if (created?.id && sourceComment.id) {
14873
15102
  this.commentIds.set(sourceComment.id, created.id);
14874
15103
  }
@@ -14889,6 +15118,18 @@ class MatterportTagCopyService {
14889
15118
  }
14890
15119
  }
14891
15120
  }
15121
+ else {
15122
+ // Same reasoning as the EQUIPMENTS phase (see the comment there): a
15123
+ // feature matching this one already exists in the target space, most
15124
+ // likely from an earlier, incomplete run. Fill in whatever identity
15125
+ // field it is missing rather than leaving it untouched.
15126
+ elementIds[sourceObj.id] = existingMatch.id;
15127
+ const healedFields = await this.healFeatureIdentity(sourceObj, existingMatch);
15128
+ if (healedFields.length > 0) {
15129
+ healedCount++;
15130
+ this.logHealedItem('featureHealed', sourceObj?.title || sourceObj?.id, healedFields);
15131
+ }
15132
+ }
14892
15133
  }
14893
15134
  catch (itemError) {
14894
15135
  console.error(`Error copying feature "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
@@ -14921,6 +15162,9 @@ class MatterportTagCopyService {
14921
15162
  dimensions: rawComment.dimensions || null,
14922
15163
  annexe: copiedAnnexe,
14923
15164
  spaceID: targetSpaceId,
15165
+ // Same authorship rule as every other copied comment — see
15166
+ // resolveCommentOwnerID().
15167
+ ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
14924
15168
  };
14925
15169
  const createdComment = await this.commentService.createComment(commentInput);
14926
15170
  if (createdComment && createdComment.id && rawComment.id) {
@@ -14987,11 +15231,17 @@ class MatterportTagCopyService {
14987
15231
  phase: 'done',
14988
15232
  processedInPhase: 0, totalInPhase: 0,
14989
15233
  processedOverall, totalOverall,
14990
- percent: 100, errorCount
15234
+ percent: 100, errorCount, healedCount
14991
15235
  });
14992
15236
  this.setStepLog('done', errorCount > 0
14993
15237
  ? this.translate.instant('matterportCopy.doneWithErrors', { count: errorCount })
14994
15238
  : this.translate.instant('matterportCopy.done'));
15239
+ // Separate summary line, on top of (not instead of) the done/doneWithErrors one
15240
+ // above: healing is orthogonal to success/failure, an already-complete run can
15241
+ // still have repaired items left over from an earlier, incomplete attempt.
15242
+ if (healedCount > 0) {
15243
+ this.setStepLog('healedSummary', this.translate.instant('matterportCopy.healedSummary', { count: healedCount }));
15244
+ }
14995
15245
  return true;
14996
15246
  }
14997
15247
  catch (error) {
@@ -15007,6 +15257,17 @@ class MatterportTagCopyService {
15007
15257
  await this.cleanupUnusedCreatedHashtags();
15008
15258
  }
15009
15259
  }
15260
+ /**
15261
+ * Serialises the current copy log (step lines as last emitted, plus every error and
15262
+ * healed-item notice) to a plain-text file and triggers a browser download — wired to a
15263
+ * "download log" button in the copy UI, so an operator can keep or share the full report
15264
+ * of a run rather than only the ~200px scrollable panel showing it.
15265
+ */
15266
+ downloadLog(filename) {
15267
+ const text = this.log.map(entry => entry.text).join('\n');
15268
+ const defaultName = `matterport-copy-log-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
15269
+ downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), filename || defaultName);
15270
+ }
15010
15271
  //
15011
15272
  // ---------- Progress / log helpers ----------
15012
15273
  //
@@ -15024,14 +15285,21 @@ class MatterportTagCopyService {
15024
15285
  }
15025
15286
  /** Appends a new, permanent log line (never overwritten), then republishes the log. */
15026
15287
  appendErrorLog(text) {
15027
- this.errorSeq++;
15028
- this.log.push({ id: `error-${this.errorSeq}`, type: 'error', text });
15288
+ this.logSeq++;
15289
+ this.log.push({ id: `error-${this.logSeq}`, type: 'error', text });
15290
+ this.copyLog$.next([...this.log]);
15291
+ }
15292
+ /** Appends a permanent log line for an item that was repaired rather than newly
15293
+ * created — see healEquipmentIdentity() and its ticket/feature equivalents. */
15294
+ appendHealedLog(text) {
15295
+ this.logSeq++;
15296
+ this.log.push({ id: `healed-${this.logSeq}`, type: 'healed', text });
15029
15297
  this.copyLog$.next([...this.log]);
15030
15298
  }
15031
15299
  /** Resets the log for a new run. */
15032
15300
  resetLog() {
15033
15301
  this.log = [];
15034
- this.errorSeq = 0;
15302
+ this.logSeq = 0;
15035
15303
  this.copyLog$.next([]);
15036
15304
  }
15037
15305
  /** Clears every id map carried over from a previous run. The service is a root-level
@@ -15045,7 +15313,11 @@ class MatterportTagCopyService {
15045
15313
  this.createdHashtagIds = new Set();
15046
15314
  this.usedHashtagIds = new Set();
15047
15315
  this.handledElementIds = new Set();
15048
- this.sameOrganisation = false;
15316
+ this.sourceMissionUserIds = new Map();
15317
+ this.targetMissionIdsByUserId = new Map();
15318
+ this.domainsById = new Map();
15319
+ this.targetVisibleDomainOrgIds = new Set();
15320
+ this.targetDomainIdsByTypeAndName = new Map();
15049
15321
  this.targetSpaceId = targetSpaceId;
15050
15322
  }
15051
15323
  /** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
@@ -15058,6 +15330,16 @@ class MatterportTagCopyService {
15058
15330
  const text = this.translate.instant(`matterportCopy.${translationKey}`, params);
15059
15331
  this.appendErrorLog(text);
15060
15332
  }
15333
+ /** Logs a translated line for an item that already existed in the target space and had
15334
+ * its missing identity fields filled in, so the operator can tell it apart from a
15335
+ * freshly created item in the copy report. */
15336
+ logHealedItem(translationKey, itemLabel, healedFields) {
15337
+ const text = this.translate.instant(`matterportCopy.${translationKey}`, {
15338
+ name: itemLabel,
15339
+ fields: healedFields.join(', ')
15340
+ });
15341
+ this.appendHealedLog(text);
15342
+ }
15061
15343
  /** Logs an item-level error, using a specific "missing file / orphaned reference"
15062
15344
  * translation key when the cause is a MissingS3FileError, and the generic per-type
15063
15345
  * key otherwise. Keeps the copy log readable: a missing file is not a bug to
@@ -15094,27 +15376,87 @@ class MatterportTagCopyService {
15094
15376
  //
15095
15377
  // ---------- Cross-space reference remapping ----------
15096
15378
  //
15379
+ /** Normalised lookup key for a domain: type disambiguates same-named domains used for
15380
+ * different purposes (equipment vs. ticket vs. feature categories, ...). */
15381
+ domainKey(type, name) {
15382
+ return `${type || ''}::${(name || '').trim().toLowerCase()}`;
15383
+ }
15097
15384
  /**
15098
- * True when both spaces belong to the same organisation.
15099
- * Domains (POI/Feature categories) are organisation-scoped records with no spaceID, so
15100
- * a domainID stays meaningful inside one organisation and only becomes a dangling
15101
- * reference across two. A failed lookup answers "no": dropping a category is recoverable,
15102
- * pointing at another organisation's domain is not.
15385
+ * READ-ONLY indexing of every domain either operator's Mission can see. Creates
15386
+ * nothing — unlike hashtags, a domain is a business taxonomy entry an operator manages
15387
+ * deliberately; this tool only ever reuses an EXISTING equivalent, it never invents one.
15388
+ *
15389
+ * Visibility is keyed to `currentMission(spaceId).organisationID` /
15390
+ * `.orderedOrganisationID` for EACH space — exactly what
15391
+ * `DomainService.getDomainsForOrgForType()` uses to populate the category dropdown on
15392
+ * the equipment/ticket/feature edit forms (see form-equipment.component.ts,
15393
+ * batch-edit-equipment.component.ts, form-feature.component.ts). Comparing the spaces'
15394
+ * OWN `organisationID` instead — the previous approach — missed every domain reachable
15395
+ * only through the operator's `orderedOrganisationID` (set when a mission is created "on
15396
+ * behalf of" another organisation — see form-mission.component.ts), which is exactly how
15397
+ * a domain ends up visible on two spaces that belong to different organisations.
15103
15398
  */
15104
- async spacesShareOrganisation(sourceSpaceId, targetSpaceId) {
15399
+ async loadDomainIndex(sourceSpaceId, targetSpaceId) {
15400
+ const sourceMission = this.userService.currentMission(sourceSpaceId);
15401
+ const targetMission = this.userService.currentMission(targetSpaceId);
15402
+ const sourceOrgIds = [sourceMission?.organisationID, sourceMission?.orderedOrganisationID]
15403
+ .filter((id) => !!id);
15404
+ const targetOrgIds = [targetMission?.organisationID, targetMission?.orderedOrganisationID]
15405
+ .filter((id) => !!id);
15406
+ this.targetVisibleDomainOrgIds = new Set(targetOrgIds);
15407
+ const allOrgIds = Array.from(new Set([...sourceOrgIds, ...targetOrgIds]));
15408
+ if (allOrgIds.length === 0) {
15409
+ // The operator has no mission on either space — nothing to resolve against;
15410
+ // every domainID will be dropped, same as an unresolvable lookup elsewhere.
15411
+ return;
15412
+ }
15105
15413
  try {
15106
- const [sourceSpace, targetSpace] = await Promise.all([
15107
- this.spaceService.getSpace(sourceSpaceId),
15108
- this.spaceService.getSpace(targetSpaceId)
15109
- ]);
15110
- const sourceOrg = sourceSpace?.organisationID;
15111
- const targetOrg = targetSpace?.organisationID;
15112
- return !!sourceOrg && !!targetOrg && sourceOrg === targetOrg;
15414
+ const domainLists = await Promise.all(allOrgIds.map(orgId => this.domainService.getAllGenericDomainsAndForOrganisation(orgId)));
15415
+ for (const list of domainLists) {
15416
+ for (const domain of list || []) {
15417
+ if (domain?.id) {
15418
+ this.domainsById.set(domain.id, domain);
15419
+ }
15420
+ }
15421
+ }
15113
15422
  }
15114
15423
  catch (error) {
15115
- console.warn('[MatterportTagCopyService] Could not compare the spaces\' organisations, categories will be dropped:', error);
15116
- return false;
15424
+ console.warn('[MatterportTagCopyService] Could not read the domains, cross-organisation categories will be dropped:', error);
15425
+ return;
15426
+ }
15427
+ for (const domain of this.domainsById.values()) {
15428
+ const visibleToTarget = !domain.organisationID || this.targetVisibleDomainOrgIds.has(domain.organisationID);
15429
+ if (visibleToTarget && domain.name) {
15430
+ this.targetDomainIdsByTypeAndName.set(this.domainKey(domain.type, domain.name), domain.id);
15431
+ }
15432
+ }
15433
+ }
15434
+ /**
15435
+ * Resolves a source domainID (equipment/ticket/feature/POI category) for use in the
15436
+ * target space.
15437
+ *
15438
+ * - generic domain (no organisationID of its own — a shared taxonomy entry, e.g. a
15439
+ * standard category created by the platform): reused unchanged, valid everywhere.
15440
+ * - the domain's own organisationID is already one the TARGET operator's mission can
15441
+ * see (its organisationID, or its orderedOrganisationID): reused unchanged too — the
15442
+ * id is directly selectable in the target space's own dropdown as-is.
15443
+ * - otherwise: matched by (type, name) against the domains visible to the TARGET
15444
+ * operator (loadDomainIndex()); dropped when no equivalent exists there.
15445
+ */
15446
+ resolveDomainID(sourceDomainId) {
15447
+ if (!sourceDomainId) {
15448
+ return null;
15449
+ }
15450
+ const domain = this.domainsById.get(sourceDomainId);
15451
+ if (!domain) {
15452
+ // Not visible to either operator's mission (lookup failed, or it belongs to
15453
+ // neither) — unchanged, safest to drop.
15454
+ return null;
15117
15455
  }
15456
+ if (!domain.organisationID || this.targetVisibleDomainOrgIds.has(domain.organisationID)) {
15457
+ return sourceDomainId;
15458
+ }
15459
+ return this.targetDomainIdsByTypeAndName.get(this.domainKey(domain.type, domain.name)) ?? null;
15118
15460
  }
15119
15461
  /** Normalised lookup key for a hashtag name, so "Moyen Âge" and "moyen âge " match. */
15120
15462
  hashtagKey(name) {
@@ -15204,6 +15546,61 @@ class MatterportTagCopyService {
15204
15546
  }
15205
15547
  return resolved.length > 0 ? resolved : null;
15206
15548
  }
15549
+ /**
15550
+ * READ-ONLY indexing of both spaces' missions.
15551
+ *
15552
+ * A Mission is scoped to one space (it pairs a userID with a spaceID), so the id a
15553
+ * source comment's `ownerID` points at never designates anyone in the target space.
15554
+ * This builds the two lookup tables resolveCommentOwnerID() needs to translate it:
15555
+ * which user a source Mission id belongs to, and which Mission (if any) that same
15556
+ * user already holds in the target space.
15557
+ */
15558
+ async loadMissionIndex(sourceSpaceId, targetSpaceId) {
15559
+ let sourceMissions = [];
15560
+ let targetMissions = [];
15561
+ try {
15562
+ [sourceMissions, targetMissions] = await Promise.all([
15563
+ this.missionService.getAllMissionsForSpace(sourceSpaceId),
15564
+ this.missionService.getAllMissionsForSpace(targetSpaceId)
15565
+ ]);
15566
+ }
15567
+ catch (error) {
15568
+ console.warn('[MatterportTagCopyService] Could not read the missions, comment authorship will be dropped:', error);
15569
+ return;
15570
+ }
15571
+ for (const mission of sourceMissions || []) {
15572
+ if (mission?.id && mission.userID) {
15573
+ this.sourceMissionUserIds.set(mission.id, mission.userID);
15574
+ }
15575
+ }
15576
+ for (const mission of targetMissions || []) {
15577
+ if (mission?.id && mission.userID && !this.targetMissionIdsByUserId.has(mission.userID)) {
15578
+ this.targetMissionIdsByUserId.set(mission.userID, mission.id);
15579
+ }
15580
+ }
15581
+ }
15582
+ /**
15583
+ * Translates a source comment's `ownerID` (a per-space Mission id) into the Mission of
15584
+ * the SAME user in the target space, so the copy keeps crediting its real author.
15585
+ *
15586
+ * Substituting the current operator's mission — the way `ownerMissionID` legitimately
15587
+ * does for tickets/equipment/features, which really do change owner on a copy — would
15588
+ * misattribute every comment to whoever ran the copy instead of who actually wrote it.
15589
+ *
15590
+ * When the original author has no mission in the target space (never granted access
15591
+ * there), the comment is left with no owner rather than a wrong one: an absent author
15592
+ * is honest, a misattributed one is not.
15593
+ */
15594
+ resolveCommentOwnerID(sourceOwnerID) {
15595
+ if (!sourceOwnerID) {
15596
+ return null;
15597
+ }
15598
+ const userID = this.sourceMissionUserIds.get(sourceOwnerID);
15599
+ if (!userID) {
15600
+ return null;
15601
+ }
15602
+ return this.targetMissionIdsByUserId.get(userID) ?? null;
15603
+ }
15207
15604
  /** Records that these hashtags are now referenced by a feature that really exists, so
15208
15605
  * the end-of-run cleanup leaves them alone. */
15209
15606
  markHashtagsUsed(hashtagIds) {
@@ -15317,16 +15714,16 @@ class MatterportTagCopyService {
15317
15714
  * items — where the whole content lives, since a TEXT comment has no file — and any
15318
15715
  * per-media settings stored in metadata.
15319
15716
  */
15320
- async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId, targetMissionId) {
15321
- return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { featureID: targetFeatureId });
15717
+ async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId) {
15718
+ return this.copyComment(sourceComment, targetSpaceId, { featureID: targetFeatureId });
15322
15719
  }
15323
15720
  /** Same, for an attachment carried by a ticket rather than by a feature. */
15324
- async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId, targetMissionId) {
15325
- return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { ticketID: targetTicketId });
15721
+ async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId) {
15722
+ return this.copyComment(sourceComment, targetSpaceId, { ticketID: targetTicketId });
15326
15723
  }
15327
15724
  /** Shared body of the two above: `owner` carries whichever foreign key links the copy back
15328
15725
  * to its new parent, everything else is identical. */
15329
- async copyComment(sourceComment, targetSpaceId, targetMissionId, owner) {
15726
+ async copyComment(sourceComment, targetSpaceId, owner) {
15330
15727
  const rawComment = sourceComment;
15331
15728
  let copiedAnnexe = null;
15332
15729
  if (rawComment.annexe) {
@@ -15344,7 +15741,11 @@ class MatterportTagCopyService {
15344
15741
  shownInTag: rawComment.shownInTag,
15345
15742
  isCoverImage: rawComment.isCoverImage,
15346
15743
  spaceID: targetSpaceId,
15347
- ownerID: targetMissionId,
15744
+ // Keeps crediting the ORIGINAL author when they also have a mission in the
15745
+ // target space, and leaves the comment un-owned otherwise — see
15746
+ // resolveCommentOwnerID(). Never the current operator's own mission: that
15747
+ // would silently reassign authorship to whoever happens to run the copy.
15748
+ ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
15348
15749
  ...owner
15349
15750
  };
15350
15751
  return this.commentService.createComment(commentInput);
@@ -15383,7 +15784,7 @@ class MatterportTagCopyService {
15383
15784
  level: raw.level ?? null,
15384
15785
  role: raw.role ?? null,
15385
15786
  category: raw.category ?? null,
15386
- domainID: this.sameOrganisation ? (raw.domainID || null) : null,
15787
+ domainID: this.resolveDomainID(raw.domainID),
15387
15788
  // --- Matterport anchoring ---
15388
15789
  metadata: this.getAdjustedPoiMetadata(sourcePoi.metadata, transform)
15389
15790
  };
@@ -15447,7 +15848,9 @@ class MatterportTagCopyService {
15447
15848
  //
15448
15849
  /**
15449
15850
  * Heuristic duplicate-detection: compares several identifying fields together rather than
15450
- * a single label, to reduce false positives when items share one of them.
15851
+ * a single label, to reduce false positives when items share one of them. Returns the
15852
+ * matched target item (or undefined) rather than a boolean, so the caller can act on the
15853
+ * existing record — heal its missing fields — instead of only skipping it.
15451
15854
  *
15452
15855
  * The fields are supplied by the caller because the models do not agree on them. The
15453
15856
  * previous version hard-coded `title`/`description`, which do not exist on Equipment at
@@ -15455,9 +15858,95 @@ class MatterportTagCopyService {
15455
15858
  * and a target space holding a single equipment silently caused the whole equipment phase
15456
15859
  * to copy nothing.
15457
15860
  */
15458
- matchesExisting(source, targetList, identity) {
15861
+ findExisting(source, targetList, identity) {
15459
15862
  const sourceKey = this.identityKey(identity(source));
15460
- return targetList.some(target => this.identityKey(identity(target)) === sourceKey);
15863
+ return targetList.find(target => this.identityKey(identity(target)) === sourceKey);
15864
+ }
15865
+ /**
15866
+ * Builds a patch of whichever fields in `fields` the existing target record is missing
15867
+ * (`null`/`undefined`/empty array) but the source has, plus `domainID` when requested —
15868
+ * domains are organisation-scoped everywhere in this file, so that one is only ever
15869
+ * healed across the SAME organisation, exactly like at creation time.
15870
+ *
15871
+ * Never overwrites a field the target already carries a value for: a value entered by
15872
+ * hand in the target space since an earlier, incomplete copy must not be clobbered by
15873
+ * an older or different source value.
15874
+ *
15875
+ * Returns both the patch to send and the list of healed field names, so the caller can
15876
+ * both perform the update and describe it in the copy log.
15877
+ */
15878
+ buildHealPatch(sourceObj, existingTarget, fields, includeDomain) {
15879
+ const patch = {};
15880
+ const healedFields = [];
15881
+ const fillIfMissing = (key, sourceValue) => {
15882
+ const currentValue = existingTarget[key];
15883
+ const isMissing = currentValue === null || currentValue === undefined
15884
+ || (Array.isArray(currentValue) && currentValue.length === 0);
15885
+ if (isMissing && sourceValue !== null && sourceValue !== undefined) {
15886
+ patch[key] = sourceValue;
15887
+ healedFields.push(key);
15888
+ }
15889
+ };
15890
+ for (const field of fields) {
15891
+ fillIfMissing(field, sourceObj[field]);
15892
+ }
15893
+ if (includeDomain && (existingTarget.domainID === null || existingTarget.domainID === undefined)) {
15894
+ const resolvedDomainId = this.resolveDomainID(sourceObj.domainID);
15895
+ if (resolvedDomainId) {
15896
+ patch.domainID = resolvedDomainId;
15897
+ healedFields.push('domainID');
15898
+ }
15899
+ }
15900
+ return { patch, healedFields };
15901
+ }
15902
+ /**
15903
+ * Fills in whichever identity field a PRE-EXISTING target equipment is missing but the
15904
+ * source has — see the call site in the EQUIPMENTS phase for why re-running the copy
15905
+ * has to be able to repair a previously-copied equipment, not just skip it again.
15906
+ *
15907
+ * A failed update is left to bubble up to the caller's existing per-item try/catch, the
15908
+ * same as a failed creation — it gets the same visible error line, rather than a silent
15909
+ * console warning.
15910
+ *
15911
+ * Returns the list of fields that were actually healed (empty if nothing was missing).
15912
+ */
15913
+ async healEquipmentIdentity(sourceObj, existingTarget) {
15914
+ const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['status', 'annotation', 'family', 'sku', 'manufacturer', 'installDate', 'warrantyEndDate', 'metadata', 'isCreatedByAI'], true);
15915
+ if (!existingTarget.photo && sourceObj.photo) {
15916
+ patch.photo = await this.copyS3KeyIfPresent(sourceObj.photo, `equipment photo "${sourceObj.name}"`);
15917
+ healedFields.push('photo');
15918
+ }
15919
+ if (healedFields.length === 0) {
15920
+ return healedFields;
15921
+ }
15922
+ patch.id = existingTarget.id;
15923
+ await this.equipmentService.updateEquipment(patch);
15924
+ return healedFields;
15925
+ }
15926
+ /**
15927
+ * Same idea as healEquipmentIdentity(), for a pre-existing ticket. `title`/`description`
15928
+ * are the pair findExisting() matched on to find this ticket in the first place, so they
15929
+ * are never part of the heal — by construction, they already agree with the source.
15930
+ */
15931
+ async healTicketIdentity(sourceObj, existingTarget) {
15932
+ const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['friendlyLabel', 'status', 'type', 'priority', 'level', 'metadata', 'read', 'projectID', 'problemCode'], true);
15933
+ if (healedFields.length === 0) {
15934
+ return healedFields;
15935
+ }
15936
+ patch.id = existingTarget.id;
15937
+ await this.ticketService.updateTicket(patch);
15938
+ return healedFields;
15939
+ }
15940
+ /** Same idea, for a pre-existing feature. Keywords, media and POIs are not healed here
15941
+ * for the same reason notes/POIs are not healed on equipment — see healEquipmentIdentity(). */
15942
+ async healFeatureIdentity(sourceObj, existingTarget) {
15943
+ const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['shortDescription', 'level', 'type', 'index', 'stripeProductID', 'stripePriceID', 'bookingPriceMetadata', 'locale'], true);
15944
+ if (healedFields.length === 0) {
15945
+ return healedFields;
15946
+ }
15947
+ patch.id = existingTarget.id;
15948
+ await this.featureService.updateFeature(patch);
15949
+ return healedFields;
15461
15950
  }
15462
15951
  /** Serialises the identifying fields unambiguously, so that ["a", "b"] and ["ab", ""]
15463
15952
  * can never produce the same key. */
@@ -15734,13 +16223,13 @@ class MatterportTagCopyService {
15734
16223
  // for the CopySource header. Do NOT re-encode it here.
15735
16224
  return extracted;
15736
16225
  }
15737
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: HashtagService }, { token: SpaceService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
16226
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: HashtagService }, { token: MissionService }, { token: OperationService }, { token: DomainService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
15738
16227
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
15739
16228
  }
15740
16229
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
15741
16230
  type: Injectable,
15742
16231
  args: [{ providedIn: 'root' }]
15743
- }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: SpaceService }, { type: i1.TranslateService }] });
16232
+ }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: MissionService }, { type: OperationService }, { type: DomainService }, { type: i1.TranslateService }] });
15744
16233
 
15745
16234
  /* eslint-disable class-methods-use-this */
15746
16235
  class OrganisationService {
@@ -15814,81 +16303,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
15814
16303
  args: ['currentAPIService']
15815
16304
  }] }] });
15816
16305
 
15817
- class DomainService {
15818
- API; //AWS
15819
- constructor(apiInjected) {
15820
- this.API = apiInjected;
15821
- }
15822
- async getGenericDomainsAndForOrganisation(organisationID) {
15823
- return this.API.__proto__.ListDomains().then((domains) => {
15824
- return domains.items.filter((dom) => {
15825
- return (dom.organisationID === organisationID || !dom.organisationID);
15826
- });
15827
- });
15828
- }
15829
- async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
15830
- // TODO: Change this (UUID can change depending on DB)
15831
- if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
15832
- // I am SmarterPlan, i can see all
15833
- return this.API.__proto__.ListDomains().then((response) => response.items);
15834
- }
15835
- const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
15836
- if (!orderedOrgID || orgID === orderedOrgID) {
15837
- return currentOrgDomains;
15838
- }
15839
- const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
15840
- const merged = [...currentOrgDomains, ...orderedOrgDomains];
15841
- return filterUniqueArrayByID(merged);
15842
- }
15843
- async getDomainsForOrgExcludingTypes(types, organisationID) {
15844
- return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
15845
- return domains.filter((dom) => !types.includes(dom.type));
15846
- });
15847
- }
15848
- async getDomain(id) {
15849
- return this.API.__proto__.GetDomain(id);
15850
- }
15851
- async updateDomain(input) {
15852
- return this.API.__proto__.UpdateDomain(input);
15853
- }
15854
- async deleteDomain(id) {
15855
- return this.API.__proto__.DeleteDomain({ id });
15856
- }
15857
- async getRootDomains(orgID) {
15858
- const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
15859
- const root = allDomains.filter((dom) => !dom.parentID);
15860
- return root.sort((a, b) => a.name.localeCompare(b.name));
15861
- }
15862
- async createDomain(input) {
15863
- return this.API.__proto__.CreateDomain(input);
15864
- }
15865
- async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
15866
- return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
15867
- return domains.filter((dom) => dom.type === type);
15868
- });
15869
- }
15870
- async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
15871
- return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
15872
- return domains.filter((dom) => dom.type === type && !dom.parentID);
15873
- });
15874
- }
15875
- async getDomainsForMissionsForOrg(orgID) {
15876
- const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
15877
- return domains.filter((dom) => !dom.parentID);
15878
- }
15879
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
15880
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
15881
- }
15882
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
15883
- type: Injectable,
15884
- args: [{
15885
- providedIn: "root",
15886
- }]
15887
- }], ctorParameters: () => [{ type: undefined, decorators: [{
15888
- type: Inject,
15889
- args: ['currentAPIService']
15890
- }] }] });
15891
-
15892
16306
  class TemplateService {
15893
16307
  API; //AWS
15894
16308
  constructor(apiInjected) {