@smarterplan/ngx-smarterplan-core 1.4.13 → 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))) {
@@ -6000,6 +6016,34 @@ const copyFileInS3 = async (sourceKey, destKey) => {
6000
6016
  const result = await Storage.copy({ key: sourceKey, level: "public" }, { key: destKey, level: "public" });
6001
6017
  return result.key;
6002
6018
  };
6019
+ /**
6020
+ * Fallback duplication: downloads the object and re-uploads it under the new key.
6021
+ *
6022
+ * Storage.copy() builds the S3 `x-amz-copy-source` header by plain string concatenation
6023
+ * (see @aws-amplify/storage AWSS3Provider.copy: `${bucket}/${srcPrefix}${srcKey}`), with no
6024
+ * URL-encoding of the key. A key holding a space, an accent or a parenthesis therefore
6025
+ * yields a header that does not match the signed canonical request, and S3 answers
6026
+ * "The request signature we calculated does not match the signature you provided".
6027
+ *
6028
+ * Round-tripping the bytes avoids that header entirely and works for any key, at the cost
6029
+ * of transferring the file through the browser — hence its use as a fallback only.
6030
+ * IMPORTANT: like copyFileInS3, this must NOT swallow errors.
6031
+ */
6032
+ const copyFileInS3ViaDownload = async (sourceKey, destKey) => {
6033
+ const downloaded = await Storage.get(sourceKey, {
6034
+ download: true,
6035
+ level: "public",
6036
+ });
6037
+ const body = downloaded?.Body;
6038
+ if (!body) {
6039
+ throw new Error(`Empty download for "${sourceKey}", cannot duplicate it`);
6040
+ }
6041
+ const result = await Storage.put(destKey, body, {
6042
+ level: "public",
6043
+ contentType: downloaded.ContentType || undefined,
6044
+ });
6045
+ return result.key;
6046
+ };
6003
6047
 
6004
6048
  function getDistanceBetweenTwoPoints(startPoint, endPoint) {
6005
6049
  const dx = endPoint.x - startPoint.x;
@@ -8771,7 +8815,9 @@ class EquipmentService extends BaseObjectService {
8771
8815
  const subEquips = [];
8772
8816
  await Promise.all(equip.subequipmentsIDs.map(async (subId) => {
8773
8817
  const subEquip = await this.API.GetEquipment(subId);
8774
- subEquips.push(subEquip);
8818
+ if (subEquip) {
8819
+ subEquips.push(subEquip);
8820
+ }
8775
8821
  }));
8776
8822
  equip.subEquipments = subEquips;
8777
8823
  }
@@ -8908,12 +8954,14 @@ class EquipmentService extends BaseObjectService {
8908
8954
  * S3 object itself has already been physically copied by the caller beforehand,
8909
8955
  * so this only needs to record the Note pointing at that new key.
8910
8956
  */
8911
- async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID) {
8957
+ async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID, dates = {}) {
8912
8958
  return this.API.__proto__.CreateNote({
8913
8959
  attachment: attachmentKey,
8914
8960
  type,
8915
8961
  equipmentID,
8916
8962
  ownerMissionID,
8963
+ signDate: dates.signDate ?? null,
8964
+ expirationDate: dates.expirationDate ?? null,
8917
8965
  });
8918
8966
  }
8919
8967
  async uploadImage(equipment, imageFile) {
@@ -9627,6 +9675,33 @@ class HashtagService {
9627
9675
  async getHashtag(hashtagID) {
9628
9676
  return this.API.__proto__.GetHashtag(hashtagID);
9629
9677
  }
9678
+ /*
9679
+ * ---------------------------------------------------------------------------
9680
+ * Pure data access — NO side effect on currentHashtags / currentSpaceID.
9681
+ *
9682
+ * `currentHashtags` is shared UI state read by HashtagFromIdPipe to render a
9683
+ * keyword's name. Batch work (space duplication, export, admin tooling) touches
9684
+ * hashtags of spaces the operator is not editing, and must not reshape what the
9685
+ * screens are displaying — hence this side-effect-free trio.
9686
+ * ---------------------------------------------------------------------------
9687
+ */
9688
+ /** Reads a space's keywords without touching the shared cache. */
9689
+ async listHashtagsForSpace(spaceID) {
9690
+ return this.API.__proto__.HashtagsBySpace(spaceID).then((response) => response.items);
9691
+ }
9692
+ /** Creates a keyword in a space without adding it to the shared cache. */
9693
+ async createHashtagForSpace(name, spaceID) {
9694
+ return this.API.__proto__.CreateHashtag({ name, spaceID });
9695
+ }
9696
+ /** Deletes a keyword without touching the shared cache. */
9697
+ async deleteHashtagById(hashtagID) {
9698
+ return this.API.__proto__.DeleteHashtag({ id: hashtagID });
9699
+ }
9700
+ /*
9701
+ * ---------------------------------------------------------------------------
9702
+ * UI-facing operations — same actions, plus maintenance of the shared cache.
9703
+ * ---------------------------------------------------------------------------
9704
+ */
9630
9705
  async createHashtag(hashtag) {
9631
9706
  const hashtagCreated = await this.API.__proto__.CreateHashtag(hashtag);
9632
9707
  this.currentHashtags.push(hashtagCreated);
@@ -9640,7 +9715,7 @@ class HashtagService {
9640
9715
  return this.currentHashtags;
9641
9716
  }
9642
9717
  this.currentSpaceID = spaceID;
9643
- return this.API.__proto__.HashtagsBySpace(spaceID).then((response) => response.items);
9718
+ return this.listHashtagsForSpace(spaceID);
9644
9719
  }
9645
9720
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
9646
9721
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagService, providedIn: "root" });
@@ -9660,14 +9735,33 @@ class HashtagFromIdPipe {
9660
9735
  constructor(hashtagService) {
9661
9736
  this.hashtagService = hashtagService;
9662
9737
  }
9738
+ /**
9739
+ * Resolves a hashtag id to its display name.
9740
+ *
9741
+ * The cache is consulted for THIS id, and the network answers whenever the id is not
9742
+ * in it.
9743
+ *
9744
+ * Hashtag ids are globally unique, so a cache holding entries from several spaces can
9745
+ * only ever produce a correct hit or a miss, never a wrong name.
9746
+ */
9663
9747
  async transform(hashtagID) {
9664
- if (this.hashtagService.currentHashtags.length === 0) {
9665
- return this.hashtagService.getHashtag(hashtagID).then((hashtag) => {
9666
- return hashtag.name;
9667
- });
9748
+ if (!hashtagID) {
9749
+ return "";
9750
+ }
9751
+ const cached = this.hashtagService.currentHashtags.find((_hashtag) => _hashtag.id === hashtagID);
9752
+ if (cached) {
9753
+ return cached.name ?? "";
9754
+ }
9755
+ try {
9756
+ const hashtag = await this.hashtagService.getHashtag(hashtagID);
9757
+ return hashtag?.name ?? "";
9758
+ }
9759
+ catch (error) {
9760
+ // A keyword that cannot be read (deleted, network hiccup) must not break the
9761
+ // rendering of the POI that carries it.
9762
+ console.warn(`[HashtagFromIdPipe] Could not resolve the keyword "${hashtagID}":`, error);
9763
+ return "";
9668
9764
  }
9669
- const hashtag = this.hashtagService.currentHashtags.find((_hashtag) => _hashtag.id === hashtagID);
9670
- return hashtag ? hashtag.name : "";
9671
9765
  }
9672
9766
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagFromIdPipe, deps: [{ token: HashtagService }], target: i0.ɵɵFactoryTarget.Pipe });
9673
9767
  static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: HashtagFromIdPipe, name: "hashtagFromID" });
@@ -10387,6 +10481,40 @@ class CommentService {
10387
10481
  async createComment(comment) {
10388
10482
  return this.API.__proto__.CreateComment(comment);
10389
10483
  }
10484
+ /**
10485
+ * Returns EVERY comment attached to a feature, following `nextToken` until the
10486
+ * connection is exhausted.
10487
+ *
10488
+ * The `comments` connection nested inside FeaturesBySpace/GetFeature is capped by
10489
+ * the AppSync resolver (`$util.defaultIfNull($context.args.limit, 100)`) and exposes
10490
+ * a `nextToken` that nobody follows, so a media-rich POI silently loses every
10491
+ * attachment past the first page. Anything that must see the complete media set of
10492
+ * a feature (duplication, export, audit) has to use this method rather than
10493
+ * `feature.comments.items`.
10494
+ */
10495
+ async getAllCommentsForFeature(featureID) {
10496
+ return this.getAllCommentsPaginated((limit, nextToken) => this.API.__proto__.CommentsByFeature(featureID, undefined, undefined, limit, nextToken), featureID);
10497
+ }
10498
+ /** Same as getAllCommentsForFeature(), for the attachments of a ticket. */
10499
+ async getAllCommentsForTicket(ticketID) {
10500
+ return this.getAllCommentsPaginated((limit, nextToken) => this.API.__proto__.CommentsByTicket(ticketID, undefined, undefined, limit, nextToken), ticketID);
10501
+ }
10502
+ /** Drains a paginated comment query until its nextToken is exhausted. */
10503
+ async getAllCommentsPaginated(fetchPage, ownerID) {
10504
+ if (!ownerID) {
10505
+ return [];
10506
+ }
10507
+ const all = [];
10508
+ let nextToken = null;
10509
+ do {
10510
+ const page = await fetchPage(1000, nextToken);
10511
+ if (page?.items?.length > 0) {
10512
+ all.push(...page.items.filter((item) => !!item));
10513
+ }
10514
+ nextToken = page?.nextToken ?? null;
10515
+ } while (nextToken);
10516
+ return all;
10517
+ }
10390
10518
  async updateComment(input) {
10391
10519
  return this.API.__proto__.UpdateComment(input);
10392
10520
  }
@@ -11668,6 +11796,25 @@ class FeatureService extends BaseObjectService {
11668
11796
  }
11669
11797
  return features;
11670
11798
  }
11799
+ /**
11800
+ * Returns EVERY feature of a space, following `nextToken` until the index is
11801
+ * exhausted, with no user/archive filtering at all.
11802
+ */
11803
+ async getAllFeaturesBySpaceRaw(spaceID) {
11804
+ if (!spaceID) {
11805
+ return [];
11806
+ }
11807
+ const all = [];
11808
+ let nextToken = null;
11809
+ do {
11810
+ const page = await this.API.__proto__.FeaturesBySpace(spaceID, undefined, undefined, 1000, nextToken);
11811
+ if (page?.items?.length > 0) {
11812
+ all.push(...page.items.filter((item) => !!item));
11813
+ }
11814
+ nextToken = page?.nextToken ?? null;
11815
+ } while (nextToken);
11816
+ return all;
11817
+ }
11671
11818
  isBookingPlace(feature) {
11672
11819
  feature.isBookingPlace = feature.type === FeatureType.DESK;
11673
11820
  }
@@ -13714,16 +13861,42 @@ function applyRigidTransformToPoint(p, t) {
13714
13861
  z: t.refTarget.z + (dx * sin + dz * cos)
13715
13862
  };
13716
13863
  }
13717
- /** Applies the Y rotation (in degrees) to an orientation angle (a pose's yRotation). */
13864
+ /**
13865
+ * Applies the Y rotation (in degrees) to an orientation angle (a pose's yRotation).
13866
+ * A null/undefined/non-numeric angle is returned untouched: "no orientation stored"
13867
+ * must stay "no orientation stored", not become the raw rotation delta.
13868
+ */
13718
13869
  function applyRigidTransformToYRotationDeg(yRotationDeg, t) {
13870
+ if (yRotationDeg === null || yRotationDeg === undefined || Number.isNaN(Number(yRotationDeg))) {
13871
+ return yRotationDeg;
13872
+ }
13719
13873
  const deltaDeg = t.rotationYRad * (180 / Math.PI);
13720
- return yRotationDeg + deltaDeg;
13874
+ return Number(yRotationDeg) + deltaDeg;
13721
13875
  }
13722
13876
  /** Euclidean distance between two points, useful to validate landmark quality. */
13723
13877
  function distance(a, b) {
13724
13878
  const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
13725
13879
  return Math.sqrt(dx * dx + dy * dy + dz * dz);
13726
13880
  }
13881
+ /**
13882
+ * Applies ONLY the rotation part of the transform to a direction vector
13883
+ * (a normal, a stem vector, ...).
13884
+ *
13885
+ * A direction has no origin, so the translation must NOT be applied to it —
13886
+ * doing so would turn a unit normal into a point somewhere in the target model.
13887
+ * Used for a POI's `metadata.normal`, which drives the orientation of the tag's
13888
+ * stem: without this rotation, a pastille copied into a space whose model is
13889
+ * rotated relative to the source ends up pointing into the wall it is stuck on.
13890
+ */
13891
+ function applyRigidTransformToDirection(v, t) {
13892
+ const cos = Math.cos(t.rotationYRad);
13893
+ const sin = Math.sin(t.rotationYRad);
13894
+ return {
13895
+ x: v.x * cos - v.z * sin,
13896
+ y: v.y, // vertical axis is not rotated
13897
+ z: v.x * sin + v.z * cos
13898
+ };
13899
+ }
13727
13900
 
13728
13901
  /* eslint-disable class-methods-use-this */
13729
13902
  class Object3DService {
@@ -14091,6 +14264,100 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
14091
14264
  args: ['currentAPIService']
14092
14265
  }] }, { type: BaseUserService }, { type: NavigatorService }, { type: ZoneChangeService }, { type: FilterService }] });
14093
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
+
14094
14361
  /** Safely extracts a readable message from an unknown catch value, including Amplify's
14095
14362
  * GraphQL error shape ({ data, errors: [{ errorType, message }] }). */
14096
14363
  function toErrorMessage(err) {
@@ -14142,6 +14409,10 @@ class MatterportTagCopyService {
14142
14409
  featureService;
14143
14410
  measurementService;
14144
14411
  commentService;
14412
+ hashtagService;
14413
+ missionService;
14414
+ operationService;
14415
+ domainService;
14145
14416
  translate;
14146
14417
  // Emits progress updates during copyTagsFromVisit(); null when idle
14147
14418
  copyProgress$ = new BehaviorSubject(null);
@@ -14149,8 +14420,60 @@ class MatterportTagCopyService {
14149
14420
  copyLog$ = new BehaviorSubject([]);
14150
14421
  spModule;
14151
14422
  log = [];
14152
- errorSeq = 0;
14153
- constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, translate) {
14423
+ /** Unique-id sequence shared by every permanent (non-step) log line — errors and healed-item notices alike. */
14424
+ logSeq = 0;
14425
+ // ---- Per-run remapping state (reset by resetRunState() at the start of every copy) ----
14426
+ /** source element id -> id of its copy in the target space. */
14427
+ elementIds = {};
14428
+ /** source Comment id -> id of its copy. Needed to remap POI.tagImageID, which is a
14429
+ * reference to a Comment record (see the schema: "connection to comment.id"). */
14430
+ commentIds = new Map();
14431
+ // Hashtags are space-scoped records, so their ids can never be carried over as-is: they
14432
+ // are matched by NAME against the target space, and the missing ones are created there.
14433
+ // That creation is deliberately lazy — see resolveHashtagIds() — and audited, so an
14434
+ // interrupted copy cannot leave keywords behind that no POI references.
14435
+ /** source Hashtag id -> its name. */
14436
+ sourceHashtagNames = new Map();
14437
+ /** normalised hashtag name -> its id in the TARGET space (pre-existing or just created). */
14438
+ targetHashtagIdsByName = new Map();
14439
+ /** ids of the hashtags this run created in the target space. */
14440
+ createdHashtagIds = new Set();
14441
+ /** ids of the hashtags actually persisted on a successfully created feature. Anything in
14442
+ * createdHashtagIds but absent here is an orphan and is deleted at the end of the run. */
14443
+ usedHashtagIds = new Set();
14444
+ /** source ids of the elements whose POIs were already created by an element phase.
14445
+ * Guards the standalone POI phase against creating a second, impoverished copy. */
14446
+ handledElementIds = new Set();
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();
14474
+ /** Target space id of the run in progress — used to re-home copied tag icons. */
14475
+ targetSpaceId = null;
14476
+ constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, missionService, operationService, domainService, translate) {
14154
14477
  this.navigationService = navigationService;
14155
14478
  this.userService = userService;
14156
14479
  this.poiService = poiService;
@@ -14160,6 +14483,10 @@ class MatterportTagCopyService {
14160
14483
  this.featureService = featureService;
14161
14484
  this.measurementService = measurementService;
14162
14485
  this.commentService = commentService;
14486
+ this.hashtagService = hashtagService;
14487
+ this.missionService = missionService;
14488
+ this.operationService = operationService;
14489
+ this.domainService = domainService;
14163
14490
  this.translate = translate;
14164
14491
  this.spModule = userService.getSpModule();
14165
14492
  }
@@ -14168,7 +14495,9 @@ class MatterportTagCopyService {
14168
14495
  //
14169
14496
  async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
14170
14497
  let errorCount = 0;
14498
+ let healedCount = 0;
14171
14499
  this.resetLog();
14500
+ this.resetRunState(targetSpaceId);
14172
14501
  try {
14173
14502
  console.log(`Copying Tags from source ${sourceSpaceId} to target ${targetSpaceId}`);
14174
14503
  this.setStepProgress('collecting', 'collecting', 0);
@@ -14176,7 +14505,7 @@ class MatterportTagCopyService {
14176
14505
  phase: 'collecting',
14177
14506
  processedInPhase: 0, totalInPhase: 0,
14178
14507
  processedOverall: 0, totalOverall: 0,
14179
- percent: 0, errorCount: 0
14508
+ percent: 0, errorCount: 0, healedCount: 0
14180
14509
  });
14181
14510
  const targetMission = this.userService.currentMission(targetSpaceId);
14182
14511
  const targetMissionId = targetMission ? targetMission.id : null;
@@ -14193,7 +14522,7 @@ class MatterportTagCopyService {
14193
14522
  this.buildNavigationPositionMap(sourceSpaceId, sourceSweepIds),
14194
14523
  this.buildNavigationPositionMap(targetSpaceId, targetSweepIds)
14195
14524
  ]);
14196
- const elementIds = {};
14525
+ const elementIds = this.elementIds;
14197
14526
  let sourceTickets = [];
14198
14527
  let sourceEquipments = [];
14199
14528
  let targetTickets = [];
@@ -14209,9 +14538,31 @@ class MatterportTagCopyService {
14209
14538
  this.setStepProgress('collecting', 'collecting', 55);
14210
14539
  const sourceObjects3D = await this.object3DService.getObjects3DBySpace(sourceSpaceId);
14211
14540
  const targetObjects3D = await this.object3DService.getObjects3DBySpace(targetSpaceId);
14212
- const sourceFeatures = await this.featureService.getFeaturesBySpaceWithoutFiltering(sourceSpaceId);
14213
- const targetFeatures = await this.featureService.getFeaturesBySpaceWithoutFiltering(targetSpaceId);
14541
+ // Paginated: a space routinely holds more than the 100 features a single AppSync
14542
+ // page returns, and the tail was silently dropped.
14543
+ //
14544
+ // The source list keeps the historical "skip what the operator archived" rule, so
14545
+ // the set of copied features does not change — but the check is now null-safe:
14546
+ // FeatureService.isArchived() dereferences currentMission(spaceID).id and threw a
14547
+ // TypeError when the operator had no mission on the source space (the common case
14548
+ // for an archived v1 space), aborting the whole copy.
14549
+ // The target list is NOT filtered: it only feeds duplicate detection, and an
14550
+ // archived feature is still a feature that exists there.
14551
+ const sourceFeatures = (await this.featureService.getAllFeaturesBySpaceRaw(sourceSpaceId))
14552
+ .filter(feature => !this.isArchivedForOperator(feature, sourceSpaceId));
14553
+ const targetFeatures = await this.featureService.getAllFeaturesBySpaceRaw(targetSpaceId);
14214
14554
  this.setStepProgress('collecting', 'collecting', 75);
14555
+ // Keywords: index both vocabularies now (read-only). The keywords the target space
14556
+ // is missing are created later, POI by POI, only for those actually used.
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);
14215
14566
  if (this.spModule == SpModule.IMMO) {
14216
14567
  sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
14217
14568
  targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
@@ -14219,12 +14570,44 @@ class MatterportTagCopyService {
14219
14570
  this.setStepProgress('collecting', 'collecting', 90);
14220
14571
  const sourcePoisAll = await this.poiService.getPoisByMatterportSweeps(sourceSweepIds);
14221
14572
  const targetPoisAll = await this.poiService.getPoisByMatterportSweeps(targetSweepIds);
14573
+ // A POI is "owned" by an element (ticket, equipment, 3D object, feature, measurement)
14574
+ // when its elementID points at one. Those POIs are created by their element's phase,
14575
+ // which is the only place that knows the element's NEW id — so they must be excluded
14576
+ // from the standalone phase, otherwise every feature ends up with two POIs: one
14577
+ // correctly linked but stripped of its tag metadata, and one carrying the metadata
14578
+ // but still pointing at the source space's element.
14579
+ //
14580
+ // Ownership is recognised by TWO independent criteria, and either one is enough to
14581
+ // keep a POI out of the standalone phase:
14582
+ //
14583
+ // - its type is one an element phase is responsible for. This is the historical
14584
+ // rule and it must be kept: a POI of that type whose element was NOT collected
14585
+ // (deleted ticket, equipment outside the current filter, ...) is an orphan, and
14586
+ // copying it standalone would recreate it with an elementID still pointing into
14587
+ // the source space — an orphan the previous version correctly refused to copy.
14588
+ // - its elementID belongs to an element we actually collected. This is the added
14589
+ // rule, needed because the type alone is not a reliable discriminator: historical
14590
+ // data also holds INFORMATION/ROOM POIs bound to a feature, which the type test
14591
+ // lets through and which would end up duplicated.
14222
14592
  const handledPoiTypes = new Set([
14223
14593
  PoiType.TICKET, PoiType.EQUIPMENT, PoiType.DATA, PoiType.DESK,
14224
14594
  PoiType.OBJECT3D, PoiType.MEASURE
14225
14595
  ]);
14226
- const sourcePois = sourcePoisAll.filter(p => !handledPoiTypes.has(p.type));
14227
- const targetPois = targetPoisAll.filter(p => !handledPoiTypes.has(p.type));
14596
+ for (const owner of [...sourceTickets, ...sourceEquipments, ...sourceObjects3D, ...sourceFeatures]) {
14597
+ if (owner?.id) {
14598
+ this.handledElementIds.add(owner.id);
14599
+ }
14600
+ }
14601
+ for (const measurement of sourceMeasurements) {
14602
+ // a measurement POI is bound to the measurement's Comment, not to the measurement
14603
+ if (measurement?.comment?.id) {
14604
+ this.handledElementIds.add(measurement.comment.id);
14605
+ }
14606
+ }
14607
+ const isOwnedByAnElement = (poi) => handledPoiTypes.has(poi.type)
14608
+ || (!!poi.elementID && this.handledElementIds.has(poi.elementID));
14609
+ const sourcePois = sourcePoisAll.filter(p => !isOwnedByAnElement(p));
14610
+ const targetPois = targetPoisAll.filter(p => !!p);
14228
14611
  this.setStepProgress('collecting', 'collecting', 100);
14229
14612
  const totalOverall = sourceTickets.length + sourceEquipments.length + sourcePois.length
14230
14613
  + sourceObjects3D.length + sourceFeatures.length + sourceMeasurements.length;
@@ -14237,7 +14620,7 @@ class MatterportTagCopyService {
14237
14620
  processedOverall,
14238
14621
  totalOverall,
14239
14622
  percent: totalOverall > 0 ? Math.round((processedOverall / totalOverall) * 100) : 0,
14240
- errorCount
14623
+ errorCount, healedCount
14241
14624
  });
14242
14625
  };
14243
14626
  if (this.spModule == SpModule.IMMO) {
@@ -14245,8 +14628,8 @@ class MatterportTagCopyService {
14245
14628
  for (let i = 0; i < sourceTickets.length; i++) {
14246
14629
  const sourceObj = sourceTickets[i];
14247
14630
  try {
14248
- const existsInTarget = this.matchesExisting(sourceObj, targetTickets);
14249
- if (!existsInTarget) {
14631
+ const existingMatch = this.findExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
14632
+ if (!existingMatch) {
14250
14633
  const rawSource = sourceObj;
14251
14634
  const ticketInput = {
14252
14635
  title: rawSource.title,
@@ -14260,28 +14643,64 @@ class MatterportTagCopyService {
14260
14643
  read: rawSource.read || null,
14261
14644
  projectID: rawSource.projectID || null,
14262
14645
  problemCode: rawSource.problemCode || null,
14263
- archivedForMissionID: rawSource.archivedForMissionID || null,
14646
+ // Missions are per-space: a source mission id designates nobody in
14647
+ // the target space. Carrying the list over would look like real data
14648
+ // while hiding the item from no one — reset it, as for features.
14649
+ archivedForMissionID: null,
14264
14650
  spaceID: targetSpaceId,
14651
+ // Remapped in the second pass below, once every ticket exists.
14265
14652
  parentID: null,
14266
- domainID: null,
14653
+ // Reused unchanged in the same organisation, matched by name
14654
+ // across two — see resolveDomainID().
14655
+ domainID: this.resolveDomainID(rawSource.domainID),
14267
14656
  ownerMissionID: targetMissionId
14268
14657
  };
14269
14658
  const createdObj = await this.ticketService.createTicket(ticketInput);
14270
14659
  if (createdObj && createdObj.id) {
14271
14660
  elementIds[sourceObj.id] = createdObj.id;
14272
14661
  }
14273
- if (sourceObj.pois.items && sourceObj.pois.items.length > 0) {
14274
- const sourcePoi = sourceObj.pois.items[0];
14275
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14276
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14277
- const poiInput = {
14278
- coordinate: JSON.stringify(adjustedCoordinate),
14279
- type: sourcePoi.type,
14280
- elementID: createdObj.id,
14281
- metadata: sourcePoi.metadata,
14282
- matterportSweepID: targetSweepId
14283
- };
14284
- await this.poiService.createPOIForSpaceID(poiInput);
14662
+ // Attachments of the ticket (photos of the incident, documents...).
14663
+ // They were never copied at all: a duplicated ticket arrived stripped
14664
+ // of every piece of evidence attached to it.
14665
+ if (createdObj) {
14666
+ let sourceComments = [];
14667
+ try {
14668
+ sourceComments = await this.commentService.getAllCommentsForTicket(sourceObj.id);
14669
+ }
14670
+ catch (commentsFetchError) {
14671
+ console.warn(`Falling back to the embedded comment list for ticket "${rawSource.title}":`, commentsFetchError);
14672
+ sourceComments = (sourceObj.comments?.items || []).filter(c => !!c);
14673
+ }
14674
+ for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14675
+ try {
14676
+ const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId);
14677
+ if (created?.id && sourceComment.id) {
14678
+ this.commentIds.set(sourceComment.id, created.id);
14679
+ }
14680
+ }
14681
+ catch (attachmentError) {
14682
+ console.error(`Error copying attachment "${sourceComment?.title}" for ticket "${sourceObj?.title}":`, attachmentError);
14683
+ errorCount++;
14684
+ this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id, {
14685
+ feature: rawSource.title || sourceObj.id
14686
+ });
14687
+ }
14688
+ }
14689
+ }
14690
+ for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
14691
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14692
+ }
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);
14285
14704
  }
14286
14705
  }
14287
14706
  }
@@ -14295,26 +14714,76 @@ class MatterportTagCopyService {
14295
14714
  const pct = sourceTickets.length > 0 ? Math.round(((i + 1) / sourceTickets.length) * 100) : 100;
14296
14715
  this.setStepProgress('tickets', 'tickets', pct);
14297
14716
  }
14298
- // EQUIPMENTS (title, POI, PHOTO and NOTES/documents all copied — files physically
14299
- // duplicated in S3 first, so the copy stays independent from the source)
14717
+ // Second pass: restore the ticket hierarchy. `parentID` had to be nulled during
14718
+ // creation (the parent may not have existed yet) and was then never restored, so
14719
+ // every sub-ticket came out detached. Same rule as for sub-equipments: a parent
14720
+ // with no copied counterpart is dropped rather than left pointing at the source
14721
+ // space's ticket.
14722
+ for (const sourceObj of sourceTickets) {
14723
+ const rawSource = sourceObj;
14724
+ const newId = elementIds[sourceObj.id];
14725
+ const newParentId = rawSource.parentID ? elementIds[rawSource.parentID] : null;
14726
+ if (newId && newParentId) {
14727
+ try {
14728
+ await this.ticketService.updateTicket({ id: newId, parentID: newParentId });
14729
+ }
14730
+ catch (parentError) {
14731
+ console.error(`Error remapping the parent ticket of "${sourceObj?.title}":`, parentError);
14732
+ errorCount++;
14733
+ this.logCopyItemError(parentError, 'errorTicket', sourceObj?.title || sourceObj?.id);
14734
+ }
14735
+ }
14736
+ }
14737
+ // EQUIPMENTS (identity, POI, PHOTO and NOTES/documents all copied — files
14738
+ // physically duplicated in S3 first, so the copy stays independent from the source)
14739
+ //
14740
+ // CAREFUL: the Equipment model has NO `title` and NO `description` column. It
14741
+ // identifies itself with `name` (String! — required) and annotates itself with
14742
+ // `annotation`. Reading `rawSource.title` therefore always yielded undefined and
14743
+ // the required `name` was never sent, which AppSync rejected with
14744
+ // "Variable 'input' has coerced Null value for NonNull type 'String!'".
14300
14745
  for (let i = 0; i < sourceEquipments.length; i++) {
14301
14746
  const sourceObj = sourceEquipments[i];
14302
14747
  try {
14303
- const existsInTarget = this.matchesExisting(sourceObj, targetEquipments);
14304
- if (!existsInTarget) {
14748
+ // Equipment has no `title`/`description`: it identifies itself by name, and by
14749
+ // serial number when one is recorded.
14750
+ const existingMatch = this.findExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
14751
+ if (!existingMatch) {
14305
14752
  const rawSource = sourceObj;
14306
14753
  let copiedPhotoKey = null;
14307
14754
  if (rawSource.photo) {
14308
- copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.title}"`);
14755
+ copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.name}"`);
14309
14756
  }
14310
14757
  const equipmentInput = {
14311
- title: rawSource.title,
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,
14765
+ annotation: rawSource.annotation ?? null,
14312
14766
  status: rawSource.status,
14313
- description: rawSource.description,
14314
- archivedForMissionID: rawSource.archivedForMissionID || null,
14767
+ // Full identity of the equipment — all of these were dropped
14768
+ family: rawSource.family ?? null,
14769
+ sku: rawSource.sku ?? null,
14770
+ serialNumber: rawSource.serialNumber ?? null,
14771
+ manufacturer: rawSource.manufacturer ?? null,
14772
+ installDate: rawSource.installDate ?? null,
14773
+ warrantyEndDate: rawSource.warrantyEndDate ?? null,
14774
+ metadata: rawSource.metadata ?? null,
14775
+ isCreatedByAI: rawSource.isCreatedByAI ?? null,
14776
+ // Missions are per-space: a source mission id designates nobody in
14777
+ // the target space. Carrying the list over would look like real data
14778
+ // while hiding the item from no one — reset it, as for features.
14779
+ archivedForMissionID: null,
14315
14780
  spaceID: targetSpaceId,
14316
- domainID: null,
14781
+ // Reused unchanged in the same organisation, matched by name
14782
+ // across two — see resolveDomainID().
14783
+ domainID: this.resolveDomainID(rawSource.domainID),
14317
14784
  photo: copiedPhotoKey,
14785
+ // `subequipmentsIDs` is deliberately absent: it is remapped to the
14786
+ // copied equipments in the second pass, once they all exist.
14318
14787
  };
14319
14788
  const createdObj = await this.equipmentService.createEquipment(equipmentInput, null, null);
14320
14789
  if (createdObj && createdObj.id) {
@@ -14324,38 +14793,59 @@ class MatterportTagCopyService {
14324
14793
  if (createdObj && rawSource.notes?.items && rawSource.notes.items.length > 0) {
14325
14794
  for (const sourceNote of rawSource.notes.items) {
14326
14795
  try {
14327
- if (!sourceNote.attachment) {
14328
- continue; // nothing to duplicate for this note
14329
- }
14330
- const copiedKey = await this.copyS3KeyIfPresent(sourceNote.attachment, `equipment note for "${sourceObj?.title}"`);
14331
- await this.equipmentService.createNoteWithExistingAttachment(createdObj.id, copiedKey, sourceNote.type, targetMissionId);
14796
+ // A note without a file is still a note: it carries a type
14797
+ // and its signature/expiry dates. Skipping it, as was done
14798
+ // before, silently dropped those records entirely.
14799
+ const copiedKey = sourceNote.attachment
14800
+ ? await this.copyS3KeyIfPresent(sourceNote.attachment, `equipment note for "${sourceObj?.name}"`)
14801
+ : null;
14802
+ await this.equipmentService.createNoteWithExistingAttachment(createdObj.id, copiedKey, sourceNote.type, targetMissionId, {
14803
+ signDate: sourceNote.signDate ?? null,
14804
+ expirationDate: sourceNote.expirationDate ?? null
14805
+ });
14332
14806
  }
14333
14807
  catch (noteError) {
14334
- console.error(`Error copying note for equipment "${sourceObj?.title}":`, noteError);
14808
+ console.error(`Error copying note for equipment "${sourceObj?.name}":`, noteError);
14335
14809
  errorCount++;
14336
- this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.title || sourceObj?.id);
14810
+ this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.name || sourceObj?.id, {
14811
+ equipment: sourceObj?.name || sourceObj?.id
14812
+ });
14337
14813
  }
14338
14814
  }
14339
14815
  }
14340
- if (sourceObj.pois.items && sourceObj.pois.items.length > 0) {
14341
- const sourcePoi = sourceObj.pois.items[0];
14342
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14343
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14344
- const poiInput = {
14345
- coordinate: JSON.stringify(adjustedCoordinate),
14346
- type: sourcePoi.type,
14347
- elementID: createdObj.id,
14348
- metadata: sourcePoi.metadata,
14349
- matterportSweepID: targetSweepId
14350
- };
14351
- await this.poiService.createPOIForSpaceID(poiInput);
14816
+ for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
14817
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14818
+ }
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);
14352
14842
  }
14353
14843
  }
14354
14844
  }
14355
14845
  catch (itemError) {
14356
- console.error(`Error copying equipment "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
14846
+ console.error(`Error copying equipment "${sourceObj?.name}" (id: ${sourceObj?.id}):`, itemError);
14357
14847
  errorCount++;
14358
- this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.title || sourceObj?.id);
14848
+ this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.name || sourceObj?.id);
14359
14849
  }
14360
14850
  processedOverall++;
14361
14851
  emitProgress('equipments', i + 1, sourceEquipments.length);
@@ -14378,53 +14868,59 @@ class MatterportTagCopyService {
14378
14868
  await this.equipmentService.updateEquipment({ id: newId, subequipmentsIDs: remapped });
14379
14869
  }
14380
14870
  catch (subEquipError) {
14381
- console.error(`Error remapping sub-equipments for "${sourceObj?.title}":`, subEquipError);
14871
+ console.error(`Error remapping sub-equipments for "${sourceObj?.name}":`, subEquipError);
14382
14872
  errorCount++;
14383
- this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.title || sourceObj?.id);
14873
+ this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.name || sourceObj?.id, {
14874
+ equipment: sourceObj?.name || sourceObj?.id
14875
+ });
14384
14876
  }
14385
14877
  }
14386
14878
  }
14387
14879
  }
14388
- }
14389
- // POIS (standalone — types already handled above are excluded)
14390
- for (let i = 0; i < sourcePois.length; i++) {
14391
- const sourcePoi = sourcePois[i];
14392
- try {
14393
- const existsInTarget = targetPois.some(tp => tp.coordinate === sourcePoi.coordinate && tp.tagDescription === sourcePoi.tagDescription);
14394
- if (!existsInTarget) {
14395
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14396
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14397
- let elementID = sourcePoi.elementID;
14398
- if (elementID && elementIds[elementID]) {
14399
- elementID = elementIds[elementID];
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);
14400
14916
  }
14401
- const copiedTagIcon = await this.duplicateTagIcon(sourcePoi.tagIcon);
14402
- const copiedTagImageID = await this.duplicateTagImageID(sourcePoi.tagImageID);
14403
- const newPoi = {
14404
- coordinate: JSON.stringify(adjustedCoordinate),
14405
- type: sourcePoi.type,
14406
- elementID: elementID,
14407
- tagDescription: sourcePoi.tagDescription,
14408
- tagIcon: copiedTagIcon,
14409
- tagImageID: copiedTagImageID,
14410
- virtual: sourcePoi.virtual,
14411
- xRotation: sourcePoi.xRotation,
14412
- yRotation: sourcePoi.yRotation,
14413
- metadata: sourcePoi.metadata,
14414
- matterportSweepID: targetSweepId
14415
- };
14416
- await this.poiService.createPOIForSpaceID(newPoi);
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);
14417
14922
  }
14418
14923
  }
14419
- catch (itemError) {
14420
- console.error(`Error copying POI (sweep: ${sourcePoi?.matterportSweepID}):`, itemError);
14421
- errorCount++;
14422
- this.logCopyItemError(itemError, 'errorPoi', sourcePoi?.matterportSweepID);
14423
- }
14424
- processedOverall++;
14425
- emitProgress('pois', i + 1, sourcePois.length);
14426
- const pct = sourcePois.length > 0 ? Math.round(((i + 1) / sourcePois.length) * 100) : 100;
14427
- this.setStepProgress('pois', 'pois', pct);
14428
14924
  }
14429
14925
  // OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
14430
14926
  for (let i = 0; i < sourceObjects3D.length; i++) {
@@ -14433,8 +14929,16 @@ class MatterportTagCopyService {
14433
14929
  const existsInTarget = targetObjects3D.some(targetObj => targetObj.name === sourceObj.name);
14434
14930
  if (!existsInTarget) {
14435
14931
  const rawSource = sourceObj;
14932
+ // `path` means two different things depending on `s3Bucket`, exactly as the
14933
+ // viewer reads it (see viewer.component.ts and matterport-object3d.service.ts):
14934
+ // - s3Bucket === true -> a key of a model file uploaded to S3, ours to duplicate;
14935
+ // - otherwise -> a reference into the 3D object library shipped with
14936
+ // the application ("3DObjects/objects/cube.dae" -> assets/3Dobjects/...),
14937
+ // which exists in every space and must be reused verbatim.
14938
+ // Copying unconditionally meant every library object failed with NoSuchKey and
14939
+ // the whole 3D object was skipped, model AND POI.
14436
14940
  let copiedPath = rawSource.path;
14437
- if (rawSource.path) {
14941
+ if (rawSource.path && rawSource.s3Bucket) {
14438
14942
  copiedPath = await this.copyS3KeyIfPresent(rawSource.path, `3D object "${rawSource.name}"`);
14439
14943
  }
14440
14944
  /**
@@ -14464,39 +14968,65 @@ class MatterportTagCopyService {
14464
14968
  // Ensure we return valid numbers
14465
14969
  return array.map(n => isNaN(n) ? 0 : n);
14466
14970
  };
14971
+ // This service is shared, but the two applications do NOT deploy the same
14972
+ // Object3D schema: `viewFrustum` exists only in smarterplan, and `scale`
14973
+ // is [Float] there but a plain Float in museum. Sending the smarterplan
14974
+ // shape to museum was rejected with "The variables input contains a field
14975
+ // that is not defined for input object type 'CreateObject3DInput'".
14976
+ //
14977
+ // The input therefore MIRRORS the source record instead of imposing a
14978
+ // shape: source and target live in the same deployment, so whatever the
14979
+ // source carries is exactly what the target accepts.
14467
14980
  const object3DInput = {
14468
14981
  path: copiedPath,
14982
+ // Tells the viewer how to interpret `path`. Dropping it turned an
14983
+ // S3-hosted model into a library reference on the copy, which the
14984
+ // viewer then tried to resolve against assets/ and failed to load.
14985
+ s3Bucket: !!rawSource.s3Bucket,
14469
14986
  name: rawSource.name,
14470
14987
  description: rawSource.description || "",
14471
- viewFrustum: !!rawSource.viewFrustum,
14472
14988
  spaceID: targetSpaceId,
14473
14989
  position: toFloatArray(rawSource.position, [0, 0, 0]),
14474
- rotation: toFloatArray(rawSource.rotation, [0, 0, 0]),
14475
- // Enforce scale to be at least 1.0 if it's invalid
14476
- scale: toFloatArray(rawSource.scale, [1, 1, 1])
14990
+ rotation: toFloatArray(rawSource.rotation, [0, 0, 0])
14477
14991
  };
14478
- // Debugging: Inspect the exact object sent to the mutation
14479
- console.log(`[DEBUG] Sending Object3D "${rawSource.name}" to API:`, JSON.stringify(object3DInput));
14992
+ if ('viewFrustum' in rawSource) {
14993
+ object3DInput.viewFrustum = !!rawSource.viewFrustum;
14994
+ }
14995
+ // Scale keeps the source's own shape: a list stays a list, a scalar stays
14996
+ // a scalar. An absent scale is left unset rather than invented, so the copy
14997
+ // renders exactly like the original.
14998
+ if (Array.isArray(rawSource.scale)) {
14999
+ object3DInput.scale = toFloatArray(rawSource.scale, [1, 1, 1]);
15000
+ }
15001
+ else if (rawSource.scale !== null && rawSource.scale !== undefined) {
15002
+ const scalarScale = parseFloat(rawSource.scale);
15003
+ object3DInput.scale = Number.isNaN(scalarScale) ? 1 : scalarScale;
15004
+ }
14480
15005
  const createdObj = await this.object3DService.create(object3DInput);
14481
15006
  if (createdObj && createdObj.id) {
14482
15007
  elementIds[sourceObj.id] = createdObj.id;
14483
15008
  }
14484
- if (sourceObj.pois?.items && sourceObj.pois.items.length > 0) {
14485
- const sourcePoi = sourceObj.pois.items[0];
14486
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14487
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14488
- const poiInput = {
14489
- coordinate: JSON.stringify(adjustedCoordinate),
14490
- type: sourcePoi.type,
14491
- elementID: createdObj.id,
14492
- metadata: sourcePoi.metadata,
14493
- matterportSweepID: targetSweepId
14494
- };
14495
- await this.poiService.createPOIForSpaceID(poiInput);
15009
+ for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
15010
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14496
15011
  }
14497
15012
  }
14498
15013
  }
14499
15014
  catch (itemError) {
15015
+ // The backend can deny creation of this whole model to the signed-in account
15016
+ // (in MUSEUM, Object3D only grants get/list to userPools). The denial is not
15017
+ // per-item, so every remaining object would fail identically: report it once,
15018
+ // as a permission problem rather than a copy error, and stop the phase instead
15019
+ // of firing one doomed mutation per object.
15020
+ if (this.isUnauthorized(itemError)) {
15021
+ const remaining = sourceObjects3D.length - i;
15022
+ console.warn(`[MatterportTagCopyService] Not allowed to create 3D objects in the target space, skipping ${remaining} of them.`, itemError);
15023
+ errorCount++;
15024
+ this.logItemError('errorObjects3dNotAllowed', { count: remaining });
15025
+ processedOverall += remaining;
15026
+ emitProgress('objects3d', sourceObjects3D.length, sourceObjects3D.length);
15027
+ this.setStepProgress('objects3d', 'objects3d', 100);
15028
+ break;
15029
+ }
14500
15030
  console.error(`Error copying object3D "${sourceObj?.name}":`, itemError);
14501
15031
  errorCount++;
14502
15032
  this.logCopyItemError(itemError, 'errorObject3d', sourceObj?.name || sourceObj?.id);
@@ -14506,76 +15036,98 @@ class MatterportTagCopyService {
14506
15036
  const pct = sourceObjects3D.length > 0 ? Math.round(((i + 1) / sourceObjects3D.length) * 100) : 100;
14507
15037
  this.setStepProgress('objects3d', 'objects3d', pct);
14508
15038
  }
14509
- // FEATURES (title, POI, and attached comments — images/videos/docs/links — all copied,
14510
- // each file physically duplicated in S3 first)
15039
+ // FEATURES — the "POI" of the MUSEUM module: its own record (title, index, locale,
15040
+ // keywords), its media set (one Comment per image/video/audio/document/link/text)
15041
+ // and its POI record (the pastille: icon, tag description, cover image).
15042
+ // Everything is copied here, files physically duplicated in S3 first.
14511
15043
  for (let i = 0; i < sourceFeatures.length; i++) {
14512
15044
  const sourceObj = sourceFeatures[i];
14513
15045
  try {
14514
- const existsInTarget = this.matchesExisting(sourceObj, targetFeatures);
14515
- if (!existsInTarget) {
15046
+ const existingMatch = this.findExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
15047
+ if (!existingMatch) {
14516
15048
  const rawSource = sourceObj;
15049
+ // Resolved just before the write: a keyword is only created in the target
15050
+ // space when the POI carrying it is about to be created.
15051
+ const resolvedHashtags = await this.resolveHashtagIds(rawSource.hashtags, targetSpaceId);
14517
15052
  const featureInput = {
14518
15053
  title: rawSource.title,
14519
15054
  shortDescription: rawSource.shortDescription,
14520
15055
  level: rawSource.level,
14521
15056
  type: rawSource.type,
14522
- archivedForMissionID: rawSource.archivedForMissionID || null,
15057
+ // `index` drives the ordering of the POI in the space's list, in the
15058
+ // lateral menu and in the guided tour. Dropping it forced a full manual
15059
+ // renumbering of every copied POI.
15060
+ index: rawSource.index ?? null,
15061
+ // Missions are per-space: source mission ids mean nothing in the target
15062
+ // space, so an archive list carried over would hide POIs from nobody
15063
+ // while looking like real data. Reset instead.
15064
+ archivedForMissionID: null,
14523
15065
  stripeProductID: rawSource.stripeProductID || null,
14524
15066
  stripePriceID: rawSource.stripePriceID || null,
14525
15067
  bookingPriceMetadata: rawSource.bookingPriceMetadata || null,
14526
15068
  locale: rawSource.locale || null,
14527
- hashtags: rawSource.hashtags || null,
15069
+ // Hashtags are space-scoped records: the ids must be translated to the
15070
+ // equivalent hashtags of the target space (created on demand).
15071
+ hashtags: resolvedHashtags,
14528
15072
  description: rawSource.description || null,
14529
- domainID: null,
15073
+ // Reused unchanged in the same organisation, matched by name
15074
+ // across two — see resolveDomainID().
15075
+ domainID: this.resolveDomainID(rawSource.domainID),
14530
15076
  spaceID: targetSpaceId,
14531
15077
  ownerMissionID: targetMissionId
14532
15078
  };
14533
15079
  const createdObj = await this.featureService.createFeature(featureInput);
14534
15080
  if (createdObj && createdObj.id) {
14535
15081
  elementIds[sourceObj.id] = createdObj.id;
15082
+ // The feature exists: its keywords are now genuinely referenced and
15083
+ // must survive the end-of-run cleanup.
15084
+ this.markHashtagsUsed(resolvedHashtags);
14536
15085
  }
14537
- if (createdObj && sourceObj.comments?.items && sourceObj.comments.items.length > 0) {
14538
- for (const sourceComment of sourceObj.comments.items) {
15086
+ if (createdObj) {
15087
+ // Read the media set through its own paginated query: the `comments`
15088
+ // connection embedded in FeaturesBySpace is capped at 100 items and its
15089
+ // nextToken was never followed, so rich POIs lost attachments silently.
15090
+ let sourceComments = [];
15091
+ try {
15092
+ sourceComments = await this.commentService.getAllCommentsForFeature(sourceObj.id);
15093
+ }
15094
+ catch (commentsFetchError) {
15095
+ console.warn(`Falling back to the embedded comment list for feature "${rawSource.title}":`, commentsFetchError);
15096
+ sourceComments = (sourceObj.comments?.items || []).filter(c => !!c);
15097
+ }
15098
+ for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14539
15099
  try {
14540
- const rawComment = sourceComment;
14541
- let copiedAnnexe = null;
14542
- if (rawComment.annexe) {
14543
- copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `feature attachment "${rawComment.title}"`);
15100
+ const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId);
15101
+ if (created?.id && sourceComment.id) {
15102
+ this.commentIds.set(sourceComment.id, created.id);
14544
15103
  }
14545
- const commentInput = {
14546
- title: rawComment.title,
14547
- type: rawComment.type,
14548
- annexe: copiedAnnexe,
14549
- externalLink: rawComment.externalLink || null,
14550
- dimensions: rawComment.dimensions || null,
14551
- index: rawComment.index,
14552
- shownInTag: rawComment.shownInTag,
14553
- isCoverImage: rawComment.isCoverImage,
14554
- featureID: createdObj.id,
14555
- spaceID: targetSpaceId,
14556
- ownerID: targetMissionId
14557
- };
14558
- await this.commentService.createComment(commentInput);
14559
15104
  }
14560
15105
  catch (attachmentError) {
14561
15106
  console.error(`Error copying attachment "${sourceComment?.title}" for feature "${sourceObj?.title}":`, attachmentError);
14562
15107
  errorCount++;
14563
- this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id);
15108
+ this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id, {
15109
+ feature: rawSource.title || sourceObj.id
15110
+ });
14564
15111
  }
14565
15112
  }
15113
+ // Every POI of the feature, not only the first one: a feature may be
15114
+ // pinned at several places, and only this phase knows the new element id.
15115
+ const sourceFeaturePois = (sourceObj.pois?.items || []).filter(p => !!p);
15116
+ for (const sourcePoi of sourceFeaturePois) {
15117
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
15118
+ }
14566
15119
  }
14567
- if (sourceObj.pois?.items && sourceObj.pois.items.length > 0) {
14568
- const sourcePoi = sourceObj.pois.items[0];
14569
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14570
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14571
- const poiInput = {
14572
- coordinate: JSON.stringify(adjustedCoordinate),
14573
- type: sourcePoi.type,
14574
- elementID: createdObj.id,
14575
- metadata: sourcePoi.metadata,
14576
- matterportSweepID: targetSweepId
14577
- };
14578
- await this.poiService.createPOIForSpaceID(poiInput);
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);
14579
15131
  }
14580
15132
  }
14581
15133
  }
@@ -14604,12 +15156,20 @@ class MatterportTagCopyService {
14604
15156
  }
14605
15157
  const commentInput = {
14606
15158
  title: rawComment.title,
15159
+ description: rawComment.description ?? null,
15160
+ metadata: rawComment.metadata ?? null,
14607
15161
  type: CommentType.MEASURE,
14608
15162
  dimensions: rawComment.dimensions || null,
14609
15163
  annexe: copiedAnnexe,
14610
15164
  spaceID: targetSpaceId,
15165
+ // Same authorship rule as every other copied comment — see
15166
+ // resolveCommentOwnerID().
15167
+ ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
14611
15168
  };
14612
15169
  const createdComment = await this.commentService.createComment(commentInput);
15170
+ if (createdComment && createdComment.id && rawComment.id) {
15171
+ this.commentIds.set(rawComment.id, createdComment.id);
15172
+ }
14613
15173
  if (createdComment && sourceMeasurement.poi) {
14614
15174
  const targetSweepId = this.findClosestTargetSweepId(sourceMeasurement.poi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14615
15175
  const adjustedPosition = this.getAdjustedPositionOnly(sourceMeasurement.poi.coordinate, transform);
@@ -14617,7 +15177,9 @@ class MatterportTagCopyService {
14617
15177
  coordinate: JSON.stringify(adjustedPosition),
14618
15178
  type: PoiType.MEASURE,
14619
15179
  elementID: createdComment.id,
14620
- metadata: sourceMeasurement.poi.metadata,
15180
+ // Same treatment as any other POI: the source's Matterport tag
15181
+ // SID must not travel, and the stem normal has to be rotated.
15182
+ metadata: this.getAdjustedPoiMetadata(sourceMeasurement.poi.metadata, transform),
14621
15183
  matterportSweepID: targetSweepId
14622
15184
  };
14623
15185
  await this.poiService.createPOIForSpaceID(poiInput);
@@ -14635,15 +15197,51 @@ class MatterportTagCopyService {
14635
15197
  this.setStepProgress('measurements', 'measurements', pct);
14636
15198
  }
14637
15199
  }
15200
+ // POIS — standalone ones only: every POI owned by an element was already created by
15201
+ // that element's phase, with the element's NEW id.
15202
+ //
15203
+ // This phase runs LAST on purpose. It used to run third, before 3D objects, features
15204
+ // and measurements existed in the target space, so `elementIds` and `commentIds` were
15205
+ // still empty when it needed them: elementID stayed pointing at the source space and
15206
+ // tagImageID (a reference to a Comment record) could never be resolved.
15207
+ for (let i = 0; i < sourcePois.length; i++) {
15208
+ const sourcePoi = sourcePois[i];
15209
+ try {
15210
+ const existsInTarget = targetPois.some(tp => tp.coordinate === sourcePoi.coordinate && tp.tagDescription === sourcePoi.tagDescription);
15211
+ if (!existsInTarget) {
15212
+ const elementID = sourcePoi.elementID
15213
+ ? (elementIds[sourcePoi.elementID] || sourcePoi.elementID)
15214
+ : sourcePoi.elementID;
15215
+ await this.createCopiedPoi(sourcePoi, elementID, sourceNavPositions, targetNavPositions, transform);
15216
+ }
15217
+ }
15218
+ catch (itemError) {
15219
+ console.error(`Error copying POI (sweep: ${sourcePoi?.matterportSweepID}):`, itemError);
15220
+ errorCount++;
15221
+ this.logCopyItemError(itemError, 'errorPoi', sourcePoi?.matterportSweepID, {
15222
+ sweep: sourcePoi?.matterportSweepID
15223
+ });
15224
+ }
15225
+ processedOverall++;
15226
+ emitProgress('pois', i + 1, sourcePois.length);
15227
+ const pct = sourcePois.length > 0 ? Math.round(((i + 1) / sourcePois.length) * 100) : 100;
15228
+ this.setStepProgress('pois', 'pois', pct);
15229
+ }
14638
15230
  this.copyProgress$.next({
14639
15231
  phase: 'done',
14640
15232
  processedInPhase: 0, totalInPhase: 0,
14641
15233
  processedOverall, totalOverall,
14642
- percent: 100, errorCount
15234
+ percent: 100, errorCount, healedCount
14643
15235
  });
14644
15236
  this.setStepLog('done', errorCount > 0
14645
15237
  ? this.translate.instant('matterportCopy.doneWithErrors', { count: errorCount })
14646
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
+ }
14647
15245
  return true;
14648
15246
  }
14649
15247
  catch (error) {
@@ -14652,6 +15250,23 @@ class MatterportTagCopyService {
14652
15250
  this.copyProgress$.next(null);
14653
15251
  return false;
14654
15252
  }
15253
+ finally {
15254
+ // Every exit path — success, partial success, or general failure — gives back the
15255
+ // keywords this run created but no copied POI ended up using. Placed in `finally`
15256
+ // so an exception thrown mid-copy cannot leave them behind either.
15257
+ await this.cleanupUnusedCreatedHashtags();
15258
+ }
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);
14655
15270
  }
14656
15271
  //
14657
15272
  // ---------- Progress / log helpers ----------
@@ -14670,16 +15285,41 @@ class MatterportTagCopyService {
14670
15285
  }
14671
15286
  /** Appends a new, permanent log line (never overwritten), then republishes the log. */
14672
15287
  appendErrorLog(text) {
14673
- this.errorSeq++;
14674
- 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 });
14675
15297
  this.copyLog$.next([...this.log]);
14676
15298
  }
14677
15299
  /** Resets the log for a new run. */
14678
15300
  resetLog() {
14679
15301
  this.log = [];
14680
- this.errorSeq = 0;
15302
+ this.logSeq = 0;
14681
15303
  this.copyLog$.next([]);
14682
15304
  }
15305
+ /** Clears every id map carried over from a previous run. The service is a root-level
15306
+ * singleton, so leftover mappings would silently link a new copy to records created
15307
+ * during the previous one. */
15308
+ resetRunState(targetSpaceId) {
15309
+ this.elementIds = {};
15310
+ this.commentIds = new Map();
15311
+ this.sourceHashtagNames = new Map();
15312
+ this.targetHashtagIdsByName = new Map();
15313
+ this.createdHashtagIds = new Set();
15314
+ this.usedHashtagIds = new Set();
15315
+ this.handledElementIds = new Set();
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();
15321
+ this.targetSpaceId = targetSpaceId;
15322
+ }
14683
15323
  /** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
14684
15324
  setStepProgress(stepId, translationKey, percent) {
14685
15325
  const label = this.translate.instant(`matterportCopy.${translationKey}`);
@@ -14690,31 +15330,628 @@ class MatterportTagCopyService {
14690
15330
  const text = this.translate.instant(`matterportCopy.${translationKey}`, params);
14691
15331
  this.appendErrorLog(text);
14692
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
+ }
14693
15343
  /** Logs an item-level error, using a specific "missing file / orphaned reference"
14694
15344
  * translation key when the cause is a MissingS3FileError, and the generic per-type
14695
15345
  * key otherwise. Keeps the copy log readable: a missing file is not a bug to
14696
- * investigate the same way a network/permission error is. */
14697
- logCopyItemError(error, genericKey, itemLabel) {
15346
+ * investigate the same way a network/permission error is.
15347
+ * `extraParams` carries the placeholders a few keys need on top of {{name}}/{{error}}
15348
+ * (e.g. errorFeatureAttachment's {{feature}}, errorPoi's {{sweep}}), which used to be
15349
+ * rendered as raw "{{feature}}" text in the log. */
15350
+ logCopyItemError(error, genericKey, itemLabel, extraParams = {}) {
14698
15351
  if (error instanceof MissingS3FileError) {
14699
15352
  this.logItemError('errorMissingS3FileSkipped', {
15353
+ ...extraParams,
14700
15354
  name: itemLabel,
14701
15355
  file: error.sourceKey
14702
15356
  });
14703
15357
  }
14704
15358
  else {
14705
- this.logItemError(genericKey, { name: itemLabel, error: toErrorMessage(error) });
15359
+ this.logItemError(genericKey, { ...extraParams, name: itemLabel, error: toErrorMessage(error) });
14706
15360
  }
14707
15361
  }
14708
- //
14709
- // ---------- Duplicate detection ----------
14710
- //
14711
15362
  /**
14712
- * Heuristic duplicate-detection: compares title AND description together instead of
14713
- * title alone, to reduce false positives when several source items happen to share
14714
- * the same title. Not a perfect guarantee, but meaningfully more robust.
15363
+ * Null-safe equivalent of FeatureService.isArchived(): true when the operator has archived
15364
+ * this feature on that space. "No mission on the space" answers false (nothing archived)
15365
+ * instead of throwing, which is what made a copy from a space the operator is not a member
15366
+ * of fail outright.
14715
15367
  */
14716
- matchesExisting(source, targetList) {
14717
- return targetList.some(t => t.title === source.title && (t.description || null) === (source.description || null));
15368
+ isArchivedForOperator(feature, spaceId) {
15369
+ const archivedFor = feature?.archivedForMissionID;
15370
+ if (!archivedFor || archivedFor.length === 0) {
15371
+ return false;
15372
+ }
15373
+ const mission = this.userService.currentMission(spaceId);
15374
+ return mission?.id ? archivedFor.includes(mission.id) : false;
15375
+ }
15376
+ //
15377
+ // ---------- Cross-space reference remapping ----------
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
+ }
15384
+ /**
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.
15398
+ */
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
+ }
15413
+ try {
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
+ }
15422
+ }
15423
+ catch (error) {
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;
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;
15460
+ }
15461
+ /** Normalised lookup key for a hashtag name, so "Moyen Âge" and "moyen âge " match. */
15462
+ hashtagKey(name) {
15463
+ return name.trim().toLowerCase();
15464
+ }
15465
+ /**
15466
+ * READ-ONLY indexing of both spaces' hashtags. Creates nothing.
15467
+ *
15468
+ * `Feature.hashtags` stores Hashtag *ids*, and a Hashtag is a per-space record, so the
15469
+ * ids have to be translated by name. The translation table is built here; the missing
15470
+ * keywords are created later, one at a time, only when a POI that actually uses them is
15471
+ * about to be written (see resolveHashtagIds()).
15472
+ */
15473
+ async loadHashtagIndex(sourceSpaceId, targetSpaceId) {
15474
+ let sourceHashtags = [];
15475
+ let targetHashtags = [];
15476
+ try {
15477
+ [sourceHashtags, targetHashtags] = await Promise.all([
15478
+ this.hashtagService.listHashtagsForSpace(sourceSpaceId),
15479
+ this.hashtagService.listHashtagsForSpace(targetSpaceId)
15480
+ ]);
15481
+ }
15482
+ catch (error) {
15483
+ console.warn('[MatterportTagCopyService] Could not read the hashtags, keywords will be dropped:', error);
15484
+ return;
15485
+ }
15486
+ for (const hashtag of sourceHashtags || []) {
15487
+ if (hashtag?.id && hashtag.name) {
15488
+ this.sourceHashtagNames.set(hashtag.id, hashtag.name);
15489
+ }
15490
+ }
15491
+ for (const hashtag of targetHashtags || []) {
15492
+ if (hashtag?.id && hashtag.name) {
15493
+ this.targetHashtagIdsByName.set(this.hashtagKey(hashtag.name), hashtag.id);
15494
+ }
15495
+ }
15496
+ }
15497
+ /**
15498
+ * Translates one feature's hashtag ids into target-space ids, creating on the fly the
15499
+ * keywords the target space does not have yet.
15500
+ *
15501
+ * Creation is LAZY on purpose. Doing it up front, for the whole source vocabulary, meant
15502
+ * that a copy which failed or was closed halfway left behind keywords no POI referenced —
15503
+ * pollution the operator had to clean up by hand. Here a keyword is only ever written
15504
+ * when a POI that carries it is about to be created, so an interrupted copy stops
15505
+ * creating keywords at the same POI it stops creating content.
15506
+ *
15507
+ * The residual window (keyword created, then createFeature fails) is closed by
15508
+ * markHashtagsUsed() + cleanupUnusedCreatedHashtags().
15509
+ *
15510
+ * A keyword that can neither be found nor created is dropped rather than left as a
15511
+ * dangling id.
15512
+ */
15513
+ async resolveHashtagIds(sourceHashtagIds, targetSpaceId) {
15514
+ if (!sourceHashtagIds || sourceHashtagIds.length === 0) {
15515
+ return null;
15516
+ }
15517
+ const resolved = [];
15518
+ for (const sourceId of sourceHashtagIds) {
15519
+ if (!sourceId) {
15520
+ continue;
15521
+ }
15522
+ const name = this.sourceHashtagNames.get(sourceId);
15523
+ if (!name) {
15524
+ // the source id points at a hashtag that no longer exists — nothing to carry over
15525
+ continue;
15526
+ }
15527
+ const key = this.hashtagKey(name);
15528
+ let targetId = this.targetHashtagIdsByName.get(key);
15529
+ if (!targetId) {
15530
+ try {
15531
+ const created = await this.hashtagService.createHashtagForSpace(name, targetSpaceId);
15532
+ targetId = created?.id;
15533
+ if (targetId) {
15534
+ this.targetHashtagIdsByName.set(key, targetId);
15535
+ this.createdHashtagIds.add(targetId);
15536
+ }
15537
+ }
15538
+ catch (error) {
15539
+ console.warn(`[MatterportTagCopyService] Could not create the hashtag "${name}" in the target space:`, error);
15540
+ continue;
15541
+ }
15542
+ }
15543
+ if (targetId && !resolved.includes(targetId)) {
15544
+ resolved.push(targetId);
15545
+ }
15546
+ }
15547
+ return resolved.length > 0 ? resolved : null;
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
+ }
15604
+ /** Records that these hashtags are now referenced by a feature that really exists, so
15605
+ * the end-of-run cleanup leaves them alone. */
15606
+ markHashtagsUsed(hashtagIds) {
15607
+ for (const id of hashtagIds || []) {
15608
+ this.usedHashtagIds.add(id);
15609
+ }
15610
+ }
15611
+ /**
15612
+ * Deletes the hashtags this run created that no copied POI ended up referencing.
15613
+ *
15614
+ * Runs on every exit path — success, general failure, or exception — so the target space
15615
+ * never keeps keywords that came from an aborted copy. Only ids created by THIS run are
15616
+ * ever considered: a pre-existing keyword of the target space is never touched, even if
15617
+ * no copied POI uses it.
15618
+ */
15619
+ async cleanupUnusedCreatedHashtags() {
15620
+ const orphans = [...this.createdHashtagIds].filter(id => !this.usedHashtagIds.has(id));
15621
+ if (orphans.length === 0) {
15622
+ return;
15623
+ }
15624
+ console.log(`[MatterportTagCopyService] Removing ${orphans.length} keyword(s) created but left unused by this copy.`);
15625
+ for (const hashtagId of orphans) {
15626
+ try {
15627
+ await this.hashtagService.deleteHashtagById(hashtagId);
15628
+ this.createdHashtagIds.delete(hashtagId);
15629
+ }
15630
+ catch (error) {
15631
+ // Cleanup is best-effort: a leftover keyword is harmless next to a failed copy,
15632
+ // and must never be the reason the whole operation reports a failure.
15633
+ console.warn(`[MatterportTagCopyService] Could not remove the unused keyword "${hashtagId}":`, error);
15634
+ }
15635
+ }
15636
+ }
15637
+ //
15638
+ // ---------- Media content (Comments) ----------
15639
+ //
15640
+ /**
15641
+ * Orders a feature's media set exactly the way the UI will read it back, and repairs
15642
+ * the ordering key when the source data cannot express the user's intended order.
15643
+ *
15644
+ * The viewer sorts each media family on its own (`setImages`, `setVideos`, `setAudio`,
15645
+ * `setDocuments`, ... all do `.sort((a, b) => a.index - b.index)`), so `index` only has
15646
+ * to be consistent WITHIN a CommentType. The attachment form however defaults `index`
15647
+ * to 1 for every new attachment, so a multi-image POI very often holds several images
15648
+ * all sharing index 1 — or null. Sorting those is left to DynamoDB's arbitrary scan
15649
+ * order, which is why the copy came out in a different order than the original.
15650
+ *
15651
+ * Strategy: sort deterministically (index, then creation date, then id — i.e. the
15652
+ * upload order the user actually saw), and only rewrite the indices of a family when
15653
+ * they are ambiguous (null or duplicated). A family whose indices are already unique
15654
+ * is copied untouched, so a deliberate ordering is never overwritten.
15655
+ */
15656
+ orderCommentsForCopy(comments) {
15657
+ const valid = (comments || []).filter(c => !!c);
15658
+ const byType = new Map();
15659
+ for (const comment of valid) {
15660
+ const key = comment.type || 'UNTYPED';
15661
+ if (!byType.has(key)) {
15662
+ byType.set(key, []);
15663
+ }
15664
+ byType.get(key).push(comment);
15665
+ }
15666
+ const ordered = [];
15667
+ for (const family of byType.values()) {
15668
+ family.sort((a, b) => {
15669
+ const ai = this.orderingIndexOf(a);
15670
+ const bi = this.orderingIndexOf(b);
15671
+ if (ai !== bi) {
15672
+ return ai - bi;
15673
+ }
15674
+ const ad = this.creationTimeOf(a);
15675
+ const bd = this.creationTimeOf(b);
15676
+ if (ad !== bd) {
15677
+ return ad - bd;
15678
+ }
15679
+ return String(a.id || '').localeCompare(String(b.id || ''));
15680
+ });
15681
+ const indices = family.map(c => c.index);
15682
+ const isAmbiguous = indices.some(i => i === null || i === undefined)
15683
+ || new Set(indices).size !== indices.length;
15684
+ family.forEach((comment, position) => {
15685
+ comment.__copyIndex = isAmbiguous ? position + 1 : comment.index;
15686
+ });
15687
+ ordered.push(...family);
15688
+ }
15689
+ return ordered;
15690
+ }
15691
+ /** Sort key for a comment's position; a missing index sorts last, not first. */
15692
+ orderingIndexOf(comment) {
15693
+ const index = comment?.index;
15694
+ return index === null || index === undefined || Number.isNaN(Number(index))
15695
+ ? Number.MAX_SAFE_INTEGER
15696
+ : Number(index);
15697
+ }
15698
+ /** Creation timestamp of a comment, used as the tie-breaker: it reflects the order in
15699
+ * which the user uploaded the media, which is the order they saw. */
15700
+ creationTimeOf(comment) {
15701
+ const createdAt = comment?.createdAt;
15702
+ if (!createdAt) {
15703
+ return 0;
15704
+ }
15705
+ const time = new Date(createdAt).getTime();
15706
+ return Number.isNaN(time) ? 0 : time;
15707
+ }
15708
+ /**
15709
+ * Copies ONE media item (image, video, audio, document, text, link, embed, iframe...)
15710
+ * of a feature, duplicating its file in S3 first when it has one.
15711
+ *
15712
+ * Every column of the Comment model is carried over. `description` and `metadata` were
15713
+ * previously dropped, which emptied the captions of images/videos, the body of TEXT
15714
+ * items — where the whole content lives, since a TEXT comment has no file — and any
15715
+ * per-media settings stored in metadata.
15716
+ */
15717
+ async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId) {
15718
+ return this.copyComment(sourceComment, targetSpaceId, { featureID: targetFeatureId });
15719
+ }
15720
+ /** Same, for an attachment carried by a ticket rather than by a feature. */
15721
+ async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId) {
15722
+ return this.copyComment(sourceComment, targetSpaceId, { ticketID: targetTicketId });
15723
+ }
15724
+ /** Shared body of the two above: `owner` carries whichever foreign key links the copy back
15725
+ * to its new parent, everything else is identical. */
15726
+ async copyComment(sourceComment, targetSpaceId, owner) {
15727
+ const rawComment = sourceComment;
15728
+ let copiedAnnexe = null;
15729
+ if (rawComment.annexe) {
15730
+ copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `attachment "${rawComment.title}"`);
15731
+ }
15732
+ const commentInput = {
15733
+ title: rawComment.title,
15734
+ description: rawComment.description ?? null,
15735
+ metadata: rawComment.metadata ?? null,
15736
+ type: rawComment.type,
15737
+ annexe: copiedAnnexe,
15738
+ externalLink: rawComment.externalLink || null,
15739
+ dimensions: rawComment.dimensions || null,
15740
+ index: rawComment.__copyIndex ?? rawComment.index ?? null,
15741
+ shownInTag: rawComment.shownInTag,
15742
+ isCoverImage: rawComment.isCoverImage,
15743
+ spaceID: targetSpaceId,
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),
15749
+ ...owner
15750
+ };
15751
+ return this.commentService.createComment(commentInput);
15752
+ }
15753
+ //
15754
+ // ---------- POI creation ----------
15755
+ //
15756
+ /**
15757
+ * Creates the target-space copy of a source POI, with EVERY column of the POI model.
15758
+ *
15759
+ * Single entry point for all phases: the element phases (ticket, equipment, 3D object,
15760
+ * feature, measurement) used to build their POI inline with only 5 fields — coordinate,
15761
+ * type, elementID, metadata, sweep — which is precisely how museum POIs lost their
15762
+ * pastille: `tagDescription` (the popup text), `tagIcon` (the custom icon) and
15763
+ * `tagImageID` (the pastille's image) never left the source space.
15764
+ */
15765
+ async createCopiedPoi(sourcePoi, elementID, sourceNavPositions, targetNavPositions, transform) {
15766
+ const raw = sourcePoi;
15767
+ const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
15768
+ const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
15769
+ const poiInput = {
15770
+ coordinate: adjustedCoordinate === null || adjustedCoordinate === undefined
15771
+ ? null
15772
+ : JSON.stringify(adjustedCoordinate),
15773
+ type: sourcePoi.type,
15774
+ elementID: elementID ?? null,
15775
+ matterportSweepID: targetSweepId,
15776
+ // --- pastille (tag popup) ---
15777
+ tagDescription: sourcePoi.tagDescription ?? null,
15778
+ tagIcon: await this.duplicateTagIcon(sourcePoi.tagIcon),
15779
+ tagImageID: this.resolveTagImageID(sourcePoi.tagImageID),
15780
+ // --- rendering / visibility flags, carried over verbatim ---
15781
+ specific: raw.specific ?? null,
15782
+ private: raw.private ?? null,
15783
+ global: raw.global ?? null,
15784
+ level: raw.level ?? null,
15785
+ role: raw.role ?? null,
15786
+ category: raw.category ?? null,
15787
+ domainID: this.resolveDomainID(raw.domainID),
15788
+ // --- Matterport anchoring ---
15789
+ metadata: this.getAdjustedPoiMetadata(sourcePoi.metadata, transform)
15790
+ };
15791
+ return this.poiService.createPOIForSpaceID(poiInput);
15792
+ }
15793
+ /**
15794
+ * Adapts a POI's `metadata` (see MattertagData.getMetadataString(): { icon, normal, sid })
15795
+ * to the target space.
15796
+ *
15797
+ * - `sid` is the id of the Mattertag inside the SOURCE Matterport model. Carried over,
15798
+ * the viewer tries to re-create the target's tag with an id belonging to another
15799
+ * model ("already in use"), so the pastille fails to attach. It is dropped: the
15800
+ * viewer then allocates a fresh sid on first render.
15801
+ * - `normal` is the stem direction. It is a direction, so only the rotation applies
15802
+ * (never the translation); without it, a copied pastille points into the wall
15803
+ * whenever the two models are not aligned on the same axis.
15804
+ * - `icon` and anything else is kept as-is.
15805
+ */
15806
+ getAdjustedPoiMetadata(metadata, transform) {
15807
+ if (!metadata) {
15808
+ return metadata ?? null;
15809
+ }
15810
+ try {
15811
+ const parsed = JSON.parse(metadata);
15812
+ if (!parsed || typeof parsed !== 'object') {
15813
+ return metadata;
15814
+ }
15815
+ if (parsed.normal
15816
+ && typeof parsed.normal === 'object'
15817
+ && typeof parsed.normal.x === 'number'
15818
+ && typeof parsed.normal.y === 'number'
15819
+ && typeof parsed.normal.z === 'number') {
15820
+ parsed.normal = applyRigidTransformToDirection(parsed.normal, transform);
15821
+ }
15822
+ delete parsed.sid;
15823
+ return JSON.stringify(parsed);
15824
+ }
15825
+ catch {
15826
+ // Not JSON, or unexpected shape — leave untouched rather than guess.
15827
+ return metadata;
15828
+ }
15829
+ }
15830
+ /**
15831
+ * Resolves a POI's `tagImageID` — which the schema documents as a "connection to
15832
+ * comment.id", i.e. the id of the Comment holding the image shown in the pastille.
15833
+ *
15834
+ * It is NOT an S3 key, so the previous "does it contain a slash?" heuristic never
15835
+ * matched and the value travelled unchanged, leaving every copied pastille pointing at
15836
+ * a Comment of the source space. It is now translated through the comment id map built
15837
+ * while duplicating the media, and dropped when it cannot be resolved (the pastille
15838
+ * then falls back to the feature's cover image instead of a broken reference).
15839
+ */
15840
+ resolveTagImageID(tagImageID) {
15841
+ if (!tagImageID) {
15842
+ return null;
15843
+ }
15844
+ return this.commentIds.get(tagImageID) ?? null;
15845
+ }
15846
+ //
15847
+ // ---------- Duplicate detection ----------
15848
+ //
15849
+ /**
15850
+ * Heuristic duplicate-detection: compares several identifying fields together rather than
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.
15854
+ *
15855
+ * The fields are supplied by the caller because the models do not agree on them. The
15856
+ * previous version hard-coded `title`/`description`, which do not exist on Equipment at
15857
+ * all: both sides read `undefined`, every equipment therefore "matched" every other one,
15858
+ * and a target space holding a single equipment silently caused the whole equipment phase
15859
+ * to copy nothing.
15860
+ */
15861
+ findExisting(source, targetList, identity) {
15862
+ const sourceKey = this.identityKey(identity(source));
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;
15950
+ }
15951
+ /** Serialises the identifying fields unambiguously, so that ["a", "b"] and ["ab", ""]
15952
+ * can never produce the same key. */
15953
+ identityKey(parts) {
15954
+ return JSON.stringify(parts.map(part => part ?? ''));
14718
15955
  }
14719
15956
  //
14720
15957
  // ---------- Sweep positions / rigid transform ----------
@@ -14808,10 +16045,12 @@ class MatterportTagCopyService {
14808
16045
  /**
14809
16046
  * Builds a new S3 key for a copied attachment and sanitizes the filename
14810
16047
  * to prevent AWS Amplify SignatureDoesNotMatch errors.
16048
+ * `folderOverride` re-homes the copy into another prefix — used for tag icons, which
16049
+ * are stored per space (`icons-tags/<spaceId>/`) and must follow the target space.
14811
16050
  */
14812
- buildCopiedAttachmentKey(sourceAnnexe) {
16051
+ buildCopiedAttachmentKey(sourceAnnexe, folderOverride = null) {
14813
16052
  const lastSlash = sourceAnnexe.lastIndexOf('/');
14814
- const folder = sourceAnnexe.substring(0, lastSlash + 1);
16053
+ const folder = folderOverride ?? sourceAnnexe.substring(0, lastSlash + 1);
14815
16054
  let filename = sourceAnnexe.substring(lastSlash + 1);
14816
16055
  // Strip a leading "<8 hex chars>_" id prefix if present
14817
16056
  const prefixMatch = filename.match(/^[0-9a-fA-F-]{8}_(.+)$/);
@@ -14831,12 +16070,12 @@ class MatterportTagCopyService {
14831
16070
  * skip the whole item entirely, rather than creating a record with a broken file link.
14832
16071
  * - Any other kind of failure (permissions, network, etc.) still throws a generic Error.
14833
16072
  */
14834
- async copyS3KeyIfPresent(key, contextLabel) {
16073
+ async copyS3KeyIfPresent(key, contextLabel, folderOverride = null) {
14835
16074
  if (!key) {
14836
16075
  return null;
14837
16076
  }
14838
16077
  const sourceKey = this.extractS3KeyFromValue(key);
14839
- const destKey = this.buildCopiedAttachmentKey(sourceKey);
16078
+ const destKey = this.buildCopiedAttachmentKey(sourceKey, folderOverride);
14840
16079
  // Log the source key to ensure it matches the real S3 object path
14841
16080
  console.log(`[DEBUG] Attempting S3 copy from source: "${sourceKey}" to dest: "${destKey}"`);
14842
16081
  try {
@@ -14847,49 +16086,101 @@ class MatterportTagCopyService {
14847
16086
  return copied;
14848
16087
  }
14849
16088
  catch (error) {
14850
- const isMissingFile = error instanceof MissingS3FileError
14851
- || error?.code === 'NoSuchKey'
14852
- || /NoSuchKey|does not exist/i.test(toErrorMessage(error));
14853
- if (isMissingFile) {
16089
+ if (this.isMissingS3File(error)) {
14854
16090
  console.warn(`[MatterportTagCopyService] Orphaned S3 reference, skipping item: "${sourceKey}" (${contextLabel})`);
14855
16091
  throw new MissingS3FileError(sourceKey, contextLabel);
14856
16092
  }
16093
+ // Storage.copy() cannot handle a key holding a space, an accent or a parenthesis:
16094
+ // it puts the key unencoded into `x-amz-copy-source`, which then disagrees with the
16095
+ // signed request. Those files are perfectly readable — only that header is broken —
16096
+ // so fall back to downloading and re-uploading them.
16097
+ if (this.isSignatureMismatch(error)) {
16098
+ console.warn(`[MatterportTagCopyService] Storage.copy() signature mismatch on "${sourceKey}", falling back to download + re-upload.`);
16099
+ try {
16100
+ const copied = await copyFileInS3ViaDownload(sourceKey, destKey);
16101
+ if (!copied) {
16102
+ throw new MissingS3FileError(sourceKey, contextLabel);
16103
+ }
16104
+ return copied;
16105
+ }
16106
+ catch (fallbackError) {
16107
+ if (this.isMissingS3File(fallbackError)) {
16108
+ throw new MissingS3FileError(sourceKey, contextLabel);
16109
+ }
16110
+ throw fallbackError;
16111
+ }
16112
+ }
14857
16113
  throw error;
14858
16114
  }
14859
16115
  }
16116
+ /** True when the failure means the source object simply is not on S3 (orphaned reference). */
16117
+ isMissingS3File(error) {
16118
+ return error instanceof MissingS3FileError
16119
+ || error?.code === 'NoSuchKey'
16120
+ || error?.name === 'NoSuchKey'
16121
+ || /NoSuchKey|does not exist|NotFound/i.test(toErrorMessage(error));
16122
+ }
16123
+ /** True when AppSync refused the operation for lack of permission, as opposed to failing it.
16124
+ * Nothing can be retried or worked around client-side: the account simply is not allowed
16125
+ * to write that model, which is a backend authorisation rule (@auth) to change. */
16126
+ isUnauthorized(error) {
16127
+ const message = toErrorMessage(error);
16128
+ return error?.errorType === 'Unauthorized'
16129
+ || /Unauthorized|Not Authorized to access/i.test(message);
16130
+ }
16131
+ /** True for the SigV4 mismatch Storage.copy() raises on keys with special characters. */
16132
+ isSignatureMismatch(error) {
16133
+ return error?.code === 'SignatureDoesNotMatch'
16134
+ || error?.name === 'SignatureDoesNotMatch'
16135
+ || /signature we calculated does not match|SignatureDoesNotMatch/i.test(toErrorMessage(error));
16136
+ }
14860
16137
  /**
14861
16138
  * Duplicates the file referenced by a POI's tagIcon (a JSON string shaped like
14862
- * { src, opacity }, per form-feature.component.ts). If the JSON can't be parsed or
14863
- * has no `src`, the value is returned unchanged rather than risk corrupting it.
16139
+ * { src, opacity }, per form-feature.component.ts).
16140
+ *
16141
+ * Two behaviours matter here:
16142
+ * - the copy is re-homed under the TARGET space's icon folder
16143
+ * (`icons-tags/<targetSpaceId>/`, the layout form-feature.component.ts uploads to).
16144
+ * Keeping the source folder produced a target space whose icons all lived under
16145
+ * another space's prefix, so cleaning up the source space broke the copy's icons.
16146
+ * - a missing icon file is NOT fatal. It used to bubble up as a MissingS3FileError and
16147
+ * abort the whole POI — losing the pastille, its text and its media because of a
16148
+ * decorative file. The POI is now created with the icon reference dropped, which
16149
+ * renders the module's default icon, and a log line reports it.
14864
16150
  */
14865
16151
  async duplicateTagIcon(tagIconRaw) {
14866
16152
  if (!tagIconRaw) {
14867
- return tagIconRaw;
16153
+ return tagIconRaw ?? null;
14868
16154
  }
16155
+ let parsed;
14869
16156
  try {
14870
- const parsed = JSON.parse(tagIconRaw);
14871
- if (parsed && parsed.src) {
14872
- parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon');
14873
- }
14874
- return JSON.stringify(parsed);
16157
+ parsed = JSON.parse(tagIconRaw);
14875
16158
  }
14876
16159
  catch {
14877
- // Not JSON, or unexpected shape — leave untouched rather than guess.
16160
+ // Not JSON — leave untouched rather than guess.
14878
16161
  return tagIconRaw;
14879
16162
  }
14880
- }
14881
- /**
14882
- * Duplicates tagImageID if it looks like a reference to a file (contains a path
14883
- * separator, whether raw key or pre-signed URL). UNCONFIRMED ASSUMPTION: this
14884
- * field's exact semantics weren't verified — if it's actually an opaque reference
14885
- * to another DB record rather than a file, this heuristic may incorrectly attempt
14886
- * a copy. Confirm the real structure to tighten this if it causes issues.
14887
- */
14888
- async duplicateTagImageID(tagImageID) {
14889
- if (!tagImageID || !tagImageID.includes('/')) {
14890
- return tagImageID;
16163
+ if (!parsed || typeof parsed !== 'object' || !parsed.src) {
16164
+ return tagIconRaw;
16165
+ }
16166
+ try {
16167
+ parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon', this.buildTargetIconFolder());
16168
+ }
16169
+ catch (iconError) {
16170
+ if (!(iconError instanceof MissingS3FileError)) {
16171
+ // A real failure (permissions, network, ...) still aborts the POI: it means
16172
+ // the copy cannot be trusted, unlike a file that is simply already gone.
16173
+ throw iconError;
16174
+ }
16175
+ console.warn(`[MatterportTagCopyService] Tag icon missing on S3, falling back to the default icon: "${iconError.sourceKey}"`);
16176
+ this.logItemError('warningTagIconMissing', { file: iconError.sourceKey });
16177
+ delete parsed.src;
14891
16178
  }
14892
- return this.copyS3KeyIfPresent(tagImageID, 'tag image');
16179
+ return JSON.stringify(parsed);
16180
+ }
16181
+ /** Icon folder of the space being copied into, matching what the feature form uploads to. */
16182
+ buildTargetIconFolder() {
16183
+ return this.targetSpaceId ? `icons-tags/${this.targetSpaceId}/` : null;
14893
16184
  }
14894
16185
  /**
14895
16186
  * Extracts and normalizes the raw S3 key from a stored value (which may be a full URL
@@ -14932,13 +16223,13 @@ class MatterportTagCopyService {
14932
16223
  // for the CopySource header. Do NOT re-encode it here.
14933
16224
  return extracted;
14934
16225
  }
14935
- 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: 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 });
14936
16227
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
14937
16228
  }
14938
16229
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
14939
16230
  type: Injectable,
14940
16231
  args: [{ providedIn: 'root' }]
14941
- }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { 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 }] });
14942
16233
 
14943
16234
  /* eslint-disable class-methods-use-this */
14944
16235
  class OrganisationService {
@@ -15012,81 +16303,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
15012
16303
  args: ['currentAPIService']
15013
16304
  }] }] });
15014
16305
 
15015
- class DomainService {
15016
- API; //AWS
15017
- constructor(apiInjected) {
15018
- this.API = apiInjected;
15019
- }
15020
- async getGenericDomainsAndForOrganisation(organisationID) {
15021
- return this.API.__proto__.ListDomains().then((domains) => {
15022
- return domains.items.filter((dom) => {
15023
- return (dom.organisationID === organisationID || !dom.organisationID);
15024
- });
15025
- });
15026
- }
15027
- async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
15028
- // TODO: Change this (UUID can change depending on DB)
15029
- if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
15030
- // I am SmarterPlan, i can see all
15031
- return this.API.__proto__.ListDomains().then((response) => response.items);
15032
- }
15033
- const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
15034
- if (!orderedOrgID || orgID === orderedOrgID) {
15035
- return currentOrgDomains;
15036
- }
15037
- const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
15038
- const merged = [...currentOrgDomains, ...orderedOrgDomains];
15039
- return filterUniqueArrayByID(merged);
15040
- }
15041
- async getDomainsForOrgExcludingTypes(types, organisationID) {
15042
- return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
15043
- return domains.filter((dom) => !types.includes(dom.type));
15044
- });
15045
- }
15046
- async getDomain(id) {
15047
- return this.API.__proto__.GetDomain(id);
15048
- }
15049
- async updateDomain(input) {
15050
- return this.API.__proto__.UpdateDomain(input);
15051
- }
15052
- async deleteDomain(id) {
15053
- return this.API.__proto__.DeleteDomain({ id });
15054
- }
15055
- async getRootDomains(orgID) {
15056
- const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
15057
- const root = allDomains.filter((dom) => !dom.parentID);
15058
- return root.sort((a, b) => a.name.localeCompare(b.name));
15059
- }
15060
- async createDomain(input) {
15061
- return this.API.__proto__.CreateDomain(input);
15062
- }
15063
- async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
15064
- return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
15065
- return domains.filter((dom) => dom.type === type);
15066
- });
15067
- }
15068
- async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
15069
- return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
15070
- return domains.filter((dom) => dom.type === type && !dom.parentID);
15071
- });
15072
- }
15073
- async getDomainsForMissionsForOrg(orgID) {
15074
- const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
15075
- return domains.filter((dom) => !dom.parentID);
15076
- }
15077
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
15078
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
15079
- }
15080
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
15081
- type: Injectable,
15082
- args: [{
15083
- providedIn: "root",
15084
- }]
15085
- }], ctorParameters: () => [{ type: undefined, decorators: [{
15086
- type: Inject,
15087
- args: ['currentAPIService']
15088
- }] }] });
15089
-
15090
16306
  class TemplateService {
15091
16307
  API; //AWS
15092
16308
  constructor(apiInjected) {
@@ -15147,5 +16363,5 @@ function floatValidator() {
15147
16363
  * Generated bundle index. Do not edit.
15148
16364
  */
15149
16365
 
15150
- export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
16366
+ export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
15151
16367
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map