@openhi/constructs 0.0.188 → 0.0.189

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.
@@ -7356,6 +7356,368 @@ async function listAppointmentsRoute(req, res) {
7356
7356
  }
7357
7357
  }
7358
7358
 
7359
+ // src/data/recurrence/constants.ts
7360
+ import {
7361
+ APPOINTMENT_SERIES_SYSTEM,
7362
+ APPOINTMENT_SERIES_OCCURRENCE_SYSTEM,
7363
+ RECURRENCE_RULE_EXTENSION_URL,
7364
+ RECURRENCE_TIMEZONE_EXTENSION_URL,
7365
+ RECURRENCE_ANCHOR_EXTENSION_URL,
7366
+ SERIES_INSTANCE_OVERRIDDEN_EXTENSION_URL
7367
+ } from "@openhi/types";
7368
+
7369
+ // src/data/recurrence/rrule.ts
7370
+ import { TZDate } from "@date-fns/tz";
7371
+ import {
7372
+ addDays,
7373
+ addMonths,
7374
+ addWeeks,
7375
+ setHours,
7376
+ setMilliseconds,
7377
+ setMinutes,
7378
+ setSeconds,
7379
+ startOfWeek
7380
+ } from "date-fns";
7381
+ var FREQ_TO_RRULE = {
7382
+ daily: "DAILY",
7383
+ weekly: "WEEKLY",
7384
+ monthly: "MONTHLY"
7385
+ };
7386
+ var RRULE_TO_FREQ = {
7387
+ DAILY: "daily",
7388
+ WEEKLY: "weekly",
7389
+ MONTHLY: "monthly"
7390
+ };
7391
+ var WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
7392
+ var WEEKDAY_OFFSET_FROM_MONDAY = {
7393
+ MO: 0,
7394
+ TU: 1,
7395
+ WE: 2,
7396
+ TH: 3,
7397
+ FR: 4,
7398
+ SA: 5,
7399
+ SU: 6
7400
+ };
7401
+ var JS_DAY_TO_WEEKDAY = {
7402
+ 0: "SU",
7403
+ 1: "MO",
7404
+ 2: "TU",
7405
+ 3: "WE",
7406
+ 4: "TH",
7407
+ 5: "FR",
7408
+ 6: "SA"
7409
+ };
7410
+ var DEFAULT_MAX_COUNT = 1e3;
7411
+ function pad(value, length) {
7412
+ return String(value).padStart(length, "0");
7413
+ }
7414
+ function formatRruleUtc(date) {
7415
+ return `${pad(date.getUTCFullYear(), 4)}${pad(date.getUTCMonth() + 1, 2)}${pad(date.getUTCDate(), 2)}T${pad(date.getUTCHours(), 2)}${pad(date.getUTCMinutes(), 2)}${pad(date.getUTCSeconds(), 2)}Z`;
7416
+ }
7417
+ function parseRruleUtc(value) {
7418
+ const match = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
7419
+ if (!match) {
7420
+ throw new Error(`Unsupported RRULE UNTIL value: ${value}`);
7421
+ }
7422
+ const [, y, mo, d, h, mi, s] = match;
7423
+ return new Date(
7424
+ Date.UTC(
7425
+ Number(y),
7426
+ Number(mo) - 1,
7427
+ Number(d),
7428
+ Number(h),
7429
+ Number(mi),
7430
+ Number(s)
7431
+ )
7432
+ );
7433
+ }
7434
+ function buildRruleString(pattern) {
7435
+ const parts = [`FREQ=${FREQ_TO_RRULE[pattern.frequency]}`];
7436
+ if (pattern.interval > 1) {
7437
+ parts.push(`INTERVAL=${pattern.interval}`);
7438
+ }
7439
+ if (pattern.frequency === "weekly" && pattern.byWeekday?.length) {
7440
+ const ordered = WEEKDAYS.filter((day) => pattern.byWeekday?.includes(day));
7441
+ parts.push(`BYDAY=${ordered.join(",")}`);
7442
+ }
7443
+ if (pattern.end.kind === "count") {
7444
+ parts.push(`COUNT=${pattern.end.count}`);
7445
+ } else if (pattern.end.kind === "until") {
7446
+ parts.push(`UNTIL=${formatRruleUtc(pattern.end.until)}`);
7447
+ }
7448
+ return parts.join(";");
7449
+ }
7450
+ var SUPPORTED_RRULE_KEYS = /* @__PURE__ */ new Set([
7451
+ "FREQ",
7452
+ "INTERVAL",
7453
+ "BYDAY",
7454
+ "COUNT",
7455
+ "UNTIL"
7456
+ ]);
7457
+ var WEEKDAY_SET = new Set(WEEKDAYS);
7458
+ function parsePositiveInt(value, key) {
7459
+ const parsed = Number(value);
7460
+ if (!Number.isInteger(parsed) || parsed < 1) {
7461
+ throw new Error(`Invalid RRULE ${key}: ${value}`);
7462
+ }
7463
+ return parsed;
7464
+ }
7465
+ function parseRruleString(rrule) {
7466
+ const map = /* @__PURE__ */ new Map();
7467
+ for (const segment of rrule.split(";")) {
7468
+ const [key, value] = segment.split("=");
7469
+ if (!key || !value) continue;
7470
+ const normalizedKey = key.trim().toUpperCase();
7471
+ if (!SUPPORTED_RRULE_KEYS.has(normalizedKey)) {
7472
+ throw new Error(`Unsupported RRULE part: ${key}`);
7473
+ }
7474
+ map.set(normalizedKey, value.trim());
7475
+ }
7476
+ const freqRaw = map.get("FREQ");
7477
+ const frequency = freqRaw ? RRULE_TO_FREQ[freqRaw.toUpperCase()] : void 0;
7478
+ if (!frequency) {
7479
+ throw new Error(`Unsupported RRULE FREQ: ${freqRaw ?? "(missing)"}`);
7480
+ }
7481
+ const interval = map.has("INTERVAL") ? parsePositiveInt(map.get("INTERVAL"), "INTERVAL") : 1;
7482
+ let byWeekday;
7483
+ const byDay = map.get("BYDAY");
7484
+ if (byDay) {
7485
+ byWeekday = byDay.split(",").map((code) => {
7486
+ const normalized = code.trim().toUpperCase();
7487
+ if (!WEEKDAY_SET.has(normalized)) {
7488
+ throw new Error(`Unsupported RRULE BYDAY value: ${code}`);
7489
+ }
7490
+ return normalized;
7491
+ });
7492
+ }
7493
+ let end = { kind: "open" };
7494
+ if (map.has("COUNT")) {
7495
+ end = {
7496
+ kind: "count",
7497
+ count: parsePositiveInt(map.get("COUNT"), "COUNT")
7498
+ };
7499
+ } else if (map.has("UNTIL")) {
7500
+ end = { kind: "until", until: parseRruleUtc(map.get("UNTIL")) };
7501
+ }
7502
+ return { frequency, interval, byWeekday, end };
7503
+ }
7504
+ function setTimeOfDay(day, source) {
7505
+ return setMilliseconds(
7506
+ setSeconds(setMinutes(setHours(day, source.h), source.m), source.s),
7507
+ source.ms
7508
+ );
7509
+ }
7510
+ function expandOccurrences(args) {
7511
+ const { pattern, seriesStart, timezone, window } = args;
7512
+ const maxCount = args.maxCount ?? DEFAULT_MAX_COUNT;
7513
+ const startZ = new TZDate(seriesStart.getTime(), timezone);
7514
+ const timeOfDay = {
7515
+ h: startZ.getHours(),
7516
+ m: startZ.getMinutes(),
7517
+ s: startZ.getSeconds(),
7518
+ ms: startZ.getMilliseconds()
7519
+ };
7520
+ const startMs = seriesStart.getTime();
7521
+ const fromMs = window.from.getTime();
7522
+ const toMs = window.to.getTime();
7523
+ const untilMs = pattern.end.kind === "until" ? pattern.end.until.getTime() : Infinity;
7524
+ const countLimit = pattern.end.kind === "count" ? pattern.end.count : Infinity;
7525
+ const results = [];
7526
+ let produced = 0;
7527
+ const accept = (candidate) => {
7528
+ const ms = candidate.getTime();
7529
+ if (ms < startMs) return true;
7530
+ if (ms > untilMs) return false;
7531
+ if (produced >= countLimit) return false;
7532
+ produced += 1;
7533
+ if (ms > toMs) return false;
7534
+ if (ms >= fromMs) results.push(new Date(ms));
7535
+ return true;
7536
+ };
7537
+ let examined = 0;
7538
+ const interval = Math.max(1, pattern.interval);
7539
+ let finished = false;
7540
+ if (pattern.frequency === "daily") {
7541
+ for (let i = 0; examined <= maxCount; i += 1, examined += 1) {
7542
+ if (!accept(addDays(startZ, i * interval))) {
7543
+ finished = true;
7544
+ break;
7545
+ }
7546
+ }
7547
+ } else if (pattern.frequency === "monthly") {
7548
+ for (let i = 0; examined <= maxCount; i += 1, examined += 1) {
7549
+ if (!accept(addMonths(startZ, i * interval))) {
7550
+ finished = true;
7551
+ break;
7552
+ }
7553
+ }
7554
+ } else {
7555
+ const days = pattern.byWeekday?.length ? pattern.byWeekday : [JS_DAY_TO_WEEKDAY[startZ.getDay()]];
7556
+ const offsets = WEEKDAYS.filter((day) => days.includes(day)).map(
7557
+ (day) => WEEKDAY_OFFSET_FROM_MONDAY[day]
7558
+ );
7559
+ const weekAnchor = startOfWeek(startZ, { weekStartsOn: 1 });
7560
+ for (let w = 0; !finished && examined <= maxCount; w += interval) {
7561
+ const weekStart = addWeeks(weekAnchor, w);
7562
+ for (const offset of offsets) {
7563
+ examined += 1;
7564
+ const occurrence = setTimeOfDay(addDays(weekStart, offset), timeOfDay);
7565
+ if (!accept(occurrence)) {
7566
+ finished = true;
7567
+ break;
7568
+ }
7569
+ }
7570
+ }
7571
+ }
7572
+ if (!finished) {
7573
+ throw new Error(
7574
+ `Recurrence expansion hit the ${maxCount}-candidate safety cap before reaching the end of the window. Raise maxCount or narrow the window.`
7575
+ );
7576
+ }
7577
+ return results;
7578
+ }
7579
+
7580
+ // src/data/recurrence/seriesInstances.ts
7581
+ import { addMinutes } from "date-fns";
7582
+
7583
+ // src/data/recurrence/membership.ts
7584
+ import {
7585
+ buildSeriesIdentifier,
7586
+ buildOccurrenceIdentifier,
7587
+ buildRecurrenceRuleExtension,
7588
+ buildRecurrenceTimezoneExtension,
7589
+ buildRecurrenceAnchorExtension,
7590
+ buildSeriesInstanceOverriddenExtension,
7591
+ getSeriesId,
7592
+ getOccurrenceIdentifier,
7593
+ getOccurrenceInstant,
7594
+ getRecurrenceRule,
7595
+ getRecurrenceTimezone,
7596
+ getRecurrenceAnchor,
7597
+ isSeriesInstanceOverridden
7598
+ } from "@openhi/types";
7599
+
7600
+ // src/data/recurrence/seriesInstances.ts
7601
+ function buildSeriesInstances(args) {
7602
+ const {
7603
+ base,
7604
+ occurrences,
7605
+ durationMinutes,
7606
+ seriesId,
7607
+ rrule,
7608
+ timezone,
7609
+ anchor
7610
+ } = args;
7611
+ const seriesIdentifier = buildSeriesIdentifier(seriesId);
7612
+ const seriesExtensions = [
7613
+ buildRecurrenceRuleExtension(rrule),
7614
+ buildRecurrenceTimezoneExtension(timezone),
7615
+ buildRecurrenceAnchorExtension(anchor)
7616
+ ];
7617
+ const baseIdentifiers = base.identifier ?? [];
7618
+ const baseExtensions = base.extension ?? [];
7619
+ return occurrences.map((start) => ({
7620
+ ...base,
7621
+ start: start.toISOString(),
7622
+ end: addMinutes(start, durationMinutes).toISOString(),
7623
+ minutesDuration: durationMinutes,
7624
+ identifier: [
7625
+ ...baseIdentifiers,
7626
+ seriesIdentifier,
7627
+ buildOccurrenceIdentifier(seriesId, start)
7628
+ ],
7629
+ extension: [...baseExtensions, ...seriesExtensions]
7630
+ }));
7631
+ }
7632
+
7633
+ // src/data/operations/data/appointment/appointment-series-common.ts
7634
+ var SeriesRequestError = class extends Error {
7635
+ };
7636
+ var SeriesNotFoundError = class extends Error {
7637
+ };
7638
+ var LOCATE_LIMIT = 500;
7639
+ async function locateSeriesInstances(opts) {
7640
+ const { context, seriesId, tableName } = opts;
7641
+ const found = await genericSearchOperation({
7642
+ resourceType: "Appointment",
7643
+ tenantId: context.tenantId,
7644
+ workspaceId: context.workspaceId,
7645
+ query: { identifier: `${APPOINTMENT_SERIES_SYSTEM}|${seriesId}` },
7646
+ resolver: defaultSearchParameterResolver,
7647
+ limit: LOCATE_LIMIT
7648
+ });
7649
+ const instances = [];
7650
+ for (const entry of found.entries) {
7651
+ let resource;
7652
+ try {
7653
+ const hydrated = await getAppointmentByIdOperation({
7654
+ context,
7655
+ id: entry.id,
7656
+ tableName
7657
+ });
7658
+ resource = hydrated.resource;
7659
+ } catch {
7660
+ continue;
7661
+ }
7662
+ instances.push({
7663
+ id: entry.id,
7664
+ resource,
7665
+ occurrenceInstant: safeOccurrenceInstant(resource)
7666
+ });
7667
+ }
7668
+ instances.sort((a, b) => {
7669
+ if (a.occurrenceInstant === null && b.occurrenceInstant === null) {
7670
+ return a.id.localeCompare(b.id);
7671
+ }
7672
+ if (a.occurrenceInstant === null) return 1;
7673
+ if (b.occurrenceInstant === null) return -1;
7674
+ return Date.parse(a.occurrenceInstant) - Date.parse(b.occurrenceInstant) || a.id.localeCompare(b.id);
7675
+ });
7676
+ return instances;
7677
+ }
7678
+ function safeOccurrenceInstant(resource) {
7679
+ try {
7680
+ return getOccurrenceInstant(resource);
7681
+ } catch {
7682
+ return null;
7683
+ }
7684
+ }
7685
+ function resolveFromOccurrence(instances, fromOccurrence) {
7686
+ const fromMs = Date.parse(fromOccurrence);
7687
+ if (Number.isNaN(fromMs)) {
7688
+ throw new SeriesRequestError(
7689
+ `fromOccurrence is not a valid ISO 8601 instant: "${fromOccurrence}".`
7690
+ );
7691
+ }
7692
+ const match = instances.some(
7693
+ (i) => i.occurrenceInstant !== null && Date.parse(i.occurrenceInstant) === fromMs
7694
+ );
7695
+ if (!match) {
7696
+ throw new SeriesRequestError(
7697
+ `fromOccurrence "${fromOccurrence}" does not match any occurrence key in the series.`
7698
+ );
7699
+ }
7700
+ return fromMs;
7701
+ }
7702
+ function isAtOrAfter(instance, boundaryMs) {
7703
+ return instance.occurrenceInstant !== null && Date.parse(instance.occurrenceInstant) >= boundaryMs;
7704
+ }
7705
+ function validateSeriesSelector(body) {
7706
+ if (typeof body.seriesId !== "string" || body.seriesId.length === 0) {
7707
+ throw new SeriesRequestError("seriesId is required.");
7708
+ }
7709
+ if (body.mode !== "following" && body.mode !== "all") {
7710
+ throw new SeriesRequestError(
7711
+ `mode must be "following" or "all"; received "${String(body.mode)}".`
7712
+ );
7713
+ }
7714
+ if (body.mode === "following" && !body.fromOccurrence) {
7715
+ throw new SeriesRequestError(
7716
+ 'fromOccurrence is required when mode is "following".'
7717
+ );
7718
+ }
7719
+ }
7720
+
7359
7721
  // src/data/operations/data/appointment/appointment-update-operation.ts
7360
7722
  async function updateAppointmentOperation(params) {
7361
7723
  const { context, id, body, tableName } = params;
@@ -7380,6 +7742,653 @@ async function updateAppointmentOperation(params) {
7380
7742
  );
7381
7743
  }
7382
7744
 
7745
+ // src/data/operations/data/appointment/appointment-cancel-series-operation.ts
7746
+ async function cancelSeriesOperation(params) {
7747
+ const { context, body, tableName } = params;
7748
+ validateSeriesSelector(body);
7749
+ const instances = await locateSeriesInstances({
7750
+ context,
7751
+ seriesId: body.seriesId,
7752
+ tableName
7753
+ });
7754
+ if (instances.length === 0) {
7755
+ throw new SeriesNotFoundError(
7756
+ `No Appointment instances found for series "${body.seriesId}".`
7757
+ );
7758
+ }
7759
+ let targets;
7760
+ if (body.mode === "following") {
7761
+ const boundaryMs = resolveFromOccurrence(instances, body.fromOccurrence);
7762
+ targets = instances.filter((i) => isAtOrAfter(i, boundaryMs));
7763
+ } else {
7764
+ targets = [...instances];
7765
+ }
7766
+ const ledger = [];
7767
+ for (const instance of targets) {
7768
+ const occurrence = instance.occurrenceInstant ?? void 0;
7769
+ if (instance.resource.status === "cancelled") {
7770
+ ledger.push({
7771
+ id: instance.id,
7772
+ occurrence,
7773
+ action: "skipped-already-cancelled"
7774
+ });
7775
+ continue;
7776
+ }
7777
+ try {
7778
+ const cancelled = {
7779
+ ...instance.resource,
7780
+ status: "cancelled"
7781
+ };
7782
+ await updateAppointmentOperation({
7783
+ context,
7784
+ id: instance.id,
7785
+ body: cancelled,
7786
+ tableName
7787
+ });
7788
+ ledger.push({ id: instance.id, occurrence, action: "cancelled" });
7789
+ } catch (err) {
7790
+ ledger.push({
7791
+ id: instance.id,
7792
+ occurrence,
7793
+ action: "failed",
7794
+ diagnostics: String(err)
7795
+ });
7796
+ }
7797
+ }
7798
+ return { seriesId: body.seriesId, mode: body.mode, ledger };
7799
+ }
7800
+
7801
+ // src/data/operations/data/appointment/appointment-create-series-operation.ts
7802
+ var SERIES_MAX_OCCURRENCES = 400;
7803
+ var FREQUENCIES = /* @__PURE__ */ new Set(["daily", "weekly", "monthly"]);
7804
+ var WEEKDAYS2 = /* @__PURE__ */ new Set(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]);
7805
+ function parseSeriesPattern(pattern) {
7806
+ if (!FREQUENCIES.has(pattern.frequency)) {
7807
+ throw new SeriesRequestError(
7808
+ `Unsupported pattern.frequency "${pattern.frequency}"; expected daily | weekly | monthly.`
7809
+ );
7810
+ }
7811
+ if (!Number.isInteger(pattern.interval) || pattern.interval < 1) {
7812
+ throw new SeriesRequestError(
7813
+ `pattern.interval must be an integer >= 1; received ${pattern.interval}.`
7814
+ );
7815
+ }
7816
+ let byWeekday;
7817
+ if (pattern.byWeekday !== void 0) {
7818
+ for (const day of pattern.byWeekday) {
7819
+ if (!WEEKDAYS2.has(day)) {
7820
+ throw new SeriesRequestError(
7821
+ `Unsupported pattern.byWeekday value "${day}".`
7822
+ );
7823
+ }
7824
+ }
7825
+ byWeekday = [...pattern.byWeekday];
7826
+ }
7827
+ const end = pattern.end;
7828
+ if (end.kind === "count") {
7829
+ if (!Number.isInteger(end.count) || end.count < 1) {
7830
+ throw new SeriesRequestError(
7831
+ `pattern.end.count must be an integer >= 1; received ${end.count}.`
7832
+ );
7833
+ }
7834
+ return {
7835
+ frequency: pattern.frequency,
7836
+ interval: pattern.interval,
7837
+ byWeekday,
7838
+ end: { kind: "count", count: end.count }
7839
+ };
7840
+ }
7841
+ if (end.kind === "until") {
7842
+ const until = new Date(end.until ?? "");
7843
+ if (Number.isNaN(until.getTime())) {
7844
+ throw new SeriesRequestError(
7845
+ `pattern.end.until is not a valid ISO 8601 instant: "${end.until}".`
7846
+ );
7847
+ }
7848
+ return {
7849
+ frequency: pattern.frequency,
7850
+ interval: pattern.interval,
7851
+ byWeekday,
7852
+ end: { kind: "until", until }
7853
+ };
7854
+ }
7855
+ throw new SeriesRequestError(
7856
+ `Unsupported pattern.end.kind "${end.kind}"; v1 accepts "count" or "until" only (open-ended series are not supported).`
7857
+ );
7858
+ }
7859
+ var WINDOW_END = /* @__PURE__ */ new Date("9999-12-31T00:00:00Z");
7860
+ function expandSeries(opts) {
7861
+ let occurrences;
7862
+ try {
7863
+ occurrences = expandOccurrences({
7864
+ pattern: opts.pattern,
7865
+ seriesStart: opts.anchor,
7866
+ timezone: opts.timezone,
7867
+ window: { from: opts.anchor, to: WINDOW_END },
7868
+ // The cap throws (never silently truncates); +1 distinguishes
7869
+ // "exactly at the ceiling" from "over it".
7870
+ maxCount: SERIES_MAX_OCCURRENCES + 1
7871
+ });
7872
+ } catch (err) {
7873
+ throw new SeriesRequestError(
7874
+ `Series expansion exceeded the ${SERIES_MAX_OCCURRENCES}-occurrence server ceiling: ${String(err)}`
7875
+ );
7876
+ }
7877
+ if (occurrences.length > SERIES_MAX_OCCURRENCES) {
7878
+ throw new SeriesRequestError(
7879
+ `Series expands to ${occurrences.length} occurrences; the server ceiling is ${SERIES_MAX_OCCURRENCES}.`
7880
+ );
7881
+ }
7882
+ return occurrences;
7883
+ }
7884
+ async function createSeriesOperation(params) {
7885
+ const { context, body, tableName } = params;
7886
+ if (typeof body.seriesId !== "string" || body.seriesId.length === 0) {
7887
+ throw new SeriesRequestError("seriesId is required.");
7888
+ }
7889
+ if (body.template === void 0 || typeof body.template !== "object") {
7890
+ throw new SeriesRequestError("template is required.");
7891
+ }
7892
+ if (!Number.isInteger(body.durationMinutes) || body.durationMinutes < 1) {
7893
+ throw new SeriesRequestError(
7894
+ `durationMinutes must be an integer >= 1; received ${body.durationMinutes}.`
7895
+ );
7896
+ }
7897
+ if (typeof body.timezone !== "string" || body.timezone.length === 0) {
7898
+ throw new SeriesRequestError("timezone is required (IANA zone).");
7899
+ }
7900
+ const anchor = new Date(body.anchor ?? "");
7901
+ if (Number.isNaN(anchor.getTime())) {
7902
+ throw new SeriesRequestError(
7903
+ `anchor is not a valid ISO 8601 instant: "${body.anchor}".`
7904
+ );
7905
+ }
7906
+ const pattern = parseSeriesPattern(
7907
+ body.pattern ?? {}
7908
+ );
7909
+ const rrule = buildRruleString(pattern);
7910
+ const occurrences = expandSeries({
7911
+ pattern,
7912
+ anchor,
7913
+ timezone: body.timezone
7914
+ });
7915
+ if (body.dryRun === true) {
7916
+ return {
7917
+ seriesId: body.seriesId,
7918
+ rrule,
7919
+ occurrences: occurrences.map((o) => o.toISOString())
7920
+ };
7921
+ }
7922
+ const existing = await locateSeriesInstances({
7923
+ context,
7924
+ seriesId: body.seriesId,
7925
+ tableName
7926
+ });
7927
+ const existingInstants = new Set(
7928
+ existing.map((i) => i.occurrenceInstant).filter((v) => v !== null).map((v) => Date.parse(v))
7929
+ );
7930
+ const {
7931
+ id: _id,
7932
+ start: _start,
7933
+ end: _end,
7934
+ ...template
7935
+ } = body.template;
7936
+ const instances = buildSeriesInstances({
7937
+ base: template,
7938
+ occurrences,
7939
+ durationMinutes: body.durationMinutes,
7940
+ seriesId: body.seriesId,
7941
+ rrule,
7942
+ timezone: body.timezone,
7943
+ anchor
7944
+ });
7945
+ const ledger = [];
7946
+ for (let i = 0; i < instances.length; i++) {
7947
+ const occurrence = occurrences[i].toISOString();
7948
+ if (existingInstants.has(occurrences[i].getTime())) {
7949
+ ledger.push({ occurrence, action: "skipped-existing" });
7950
+ continue;
7951
+ }
7952
+ try {
7953
+ const created = await createAppointmentOperation({
7954
+ context,
7955
+ body: instances[i],
7956
+ tableName
7957
+ });
7958
+ ledger.push({ id: created.id, occurrence, action: "created" });
7959
+ } catch (err) {
7960
+ ledger.push({ occurrence, action: "failed", diagnostics: String(err) });
7961
+ }
7962
+ }
7963
+ return {
7964
+ seriesId: body.seriesId,
7965
+ rrule,
7966
+ occurrences: occurrences.map((o) => o.toISOString()),
7967
+ ledger
7968
+ };
7969
+ }
7970
+
7971
+ // src/data/operations/data/appointment/appointment-edit-series-operation.ts
7972
+ import { randomUUID } from "crypto";
7973
+ import { TZDate as TZDate2 } from "@date-fns/tz";
7974
+ import {
7975
+ addMinutes as addMinutes2,
7976
+ setHours as setHours2,
7977
+ setMilliseconds as setMilliseconds2,
7978
+ setMinutes as setMinutes2,
7979
+ setSeconds as setSeconds2
7980
+ } from "date-fns";
7981
+ var LOCAL_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
7982
+ function validateChanges(changes) {
7983
+ if (changes === void 0 || typeof changes !== "object") {
7984
+ throw new SeriesRequestError("changes is required.");
7985
+ }
7986
+ const named = changes.startTime !== void 0 || changes.practitioners !== void 0 || changes.patients !== void 0 || changes.appointmentType !== void 0 || changes.description !== void 0;
7987
+ if (!named) {
7988
+ throw new SeriesRequestError(
7989
+ "changes must name at least one of: startTime, practitioners, patients, appointmentType, description."
7990
+ );
7991
+ }
7992
+ if (changes.startTime !== void 0) {
7993
+ if (!LOCAL_TIME_PATTERN.test(changes.startTime.localTime ?? "")) {
7994
+ throw new SeriesRequestError(
7995
+ `changes.startTime.localTime must be wall-clock "HH:mm"; received "${changes.startTime.localTime}".`
7996
+ );
7997
+ }
7998
+ if (!Number.isInteger(changes.startTime.durationMinutes) || changes.startTime.durationMinutes < 1) {
7999
+ throw new SeriesRequestError(
8000
+ "changes.startTime.durationMinutes must be an integer >= 1."
8001
+ );
8002
+ }
8003
+ }
8004
+ validateRoleParticipants(changes.practitioners, "Practitioner/");
8005
+ validateRoleParticipants(changes.patients, "Patient/");
8006
+ }
8007
+ function validateRoleParticipants(participants, rolePrefix) {
8008
+ if (participants === void 0) return;
8009
+ for (const p of participants) {
8010
+ const reference = p.actor?.reference ?? "";
8011
+ if (!reference.startsWith(rolePrefix)) {
8012
+ throw new SeriesRequestError(
8013
+ `Every entry in the ${rolePrefix.slice(0, -1)} participant list must reference a ${rolePrefix.slice(0, -1)} (got "${reference}").`
8014
+ );
8015
+ }
8016
+ }
8017
+ }
8018
+ function replaceRoleParticipants(existing, rolePrefix, replacements) {
8019
+ const others = existing.filter(
8020
+ (p) => !(p.actor?.reference ?? "").startsWith(rolePrefix)
8021
+ );
8022
+ return [...others, ...replacements];
8023
+ }
8024
+ function deriveWallClockStart(occurrenceInstant, timezone, localTime) {
8025
+ const [hours, minutes] = localTime.split(":").map(Number);
8026
+ const onDate = new TZDate2(Date.parse(occurrenceInstant), timezone);
8027
+ const adjusted = setMilliseconds2(
8028
+ setSeconds2(setMinutes2(setHours2(onDate, hours), minutes), 0),
8029
+ 0
8030
+ );
8031
+ return new Date(adjusted.getTime());
8032
+ }
8033
+ function applyChanges(instance, changes, timezone) {
8034
+ const resource = { ...instance.resource };
8035
+ if (changes.startTime !== void 0) {
8036
+ if (instance.occurrenceInstant === null) {
8037
+ throw new SeriesRequestError(
8038
+ `Instance ${instance.id} has no occurrence identifier; cannot re-derive its start time.`
8039
+ );
8040
+ }
8041
+ const start = deriveWallClockStart(
8042
+ instance.occurrenceInstant,
8043
+ timezone,
8044
+ changes.startTime.localTime
8045
+ );
8046
+ resource.start = start.toISOString();
8047
+ resource.end = addMinutes2(
8048
+ start,
8049
+ changes.startTime.durationMinutes
8050
+ ).toISOString();
8051
+ resource.minutesDuration = changes.startTime.durationMinutes;
8052
+ }
8053
+ if (changes.practitioners !== void 0 || changes.patients !== void 0) {
8054
+ let participants = [...resource.participant ?? []];
8055
+ if (changes.practitioners !== void 0) {
8056
+ participants = replaceRoleParticipants(
8057
+ participants,
8058
+ "Practitioner/",
8059
+ changes.practitioners
8060
+ );
8061
+ }
8062
+ if (changes.patients !== void 0) {
8063
+ participants = replaceRoleParticipants(
8064
+ participants,
8065
+ "Patient/",
8066
+ changes.patients
8067
+ );
8068
+ }
8069
+ resource.participant = participants;
8070
+ }
8071
+ if (changes.appointmentType !== void 0) {
8072
+ resource.appointmentType = changes.appointmentType;
8073
+ }
8074
+ if (changes.description !== void 0) {
8075
+ resource.description = changes.description;
8076
+ }
8077
+ return resource;
8078
+ }
8079
+ function readSeriesRecurrence(instances) {
8080
+ for (const instance of instances) {
8081
+ const rule = getRecurrenceRule(instance.resource);
8082
+ const timezone = getRecurrenceTimezone(instance.resource);
8083
+ const anchor = getRecurrenceAnchor(instance.resource);
8084
+ if (rule !== null && timezone !== null && anchor !== null) {
8085
+ return { pattern: parseRruleString(rule), timezone, anchor };
8086
+ }
8087
+ }
8088
+ throw new SeriesRequestError(
8089
+ "Series instances are missing the recurrence-rule / recurrence-timezone / recurrence-anchor extensions; the series cannot be edited as a series."
8090
+ );
8091
+ }
8092
+ function withTruncatedRule(resource, rule) {
8093
+ const extension = (resource.extension ?? []).map(
8094
+ (ext) => ext.url === RECURRENCE_RULE_EXTENSION_URL ? { ...ext, valueString: rule } : ext
8095
+ );
8096
+ return { ...resource, extension };
8097
+ }
8098
+ function buildTailTemplate(resource) {
8099
+ const { id: _id, start: _start, end: _end, meta: _meta, ...rest } = resource;
8100
+ const identifier = (rest.identifier ?? []).filter(
8101
+ (i) => i.system !== APPOINTMENT_SERIES_SYSTEM && i.system !== APPOINTMENT_SERIES_OCCURRENCE_SYSTEM
8102
+ );
8103
+ const stripped = /* @__PURE__ */ new Set([
8104
+ RECURRENCE_RULE_EXTENSION_URL,
8105
+ RECURRENCE_TIMEZONE_EXTENSION_URL,
8106
+ RECURRENCE_ANCHOR_EXTENSION_URL,
8107
+ SERIES_INSTANCE_OVERRIDDEN_EXTENSION_URL
8108
+ ]);
8109
+ const extension = (rest.extension ?? []).filter(
8110
+ (e) => !stripped.has(e.url ?? "")
8111
+ );
8112
+ return {
8113
+ ...rest,
8114
+ identifier,
8115
+ extension,
8116
+ // A tail regenerated from a cancelled instance must not be born cancelled.
8117
+ status: rest.status === "cancelled" ? "booked" : rest.status
8118
+ };
8119
+ }
8120
+ async function editSeriesOperation(params) {
8121
+ const { context, body, tableName } = params;
8122
+ validateSeriesSelector(body);
8123
+ validateChanges(body.changes);
8124
+ if (body.newPattern !== void 0 && body.mode !== "following") {
8125
+ throw new SeriesRequestError(
8126
+ 'newPattern is only valid with mode="following".'
8127
+ );
8128
+ }
8129
+ const instances = await locateSeriesInstances({
8130
+ context,
8131
+ seriesId: body.seriesId,
8132
+ tableName
8133
+ });
8134
+ if (instances.length === 0) {
8135
+ throw new SeriesNotFoundError(
8136
+ `No Appointment instances found for series "${body.seriesId}".`
8137
+ );
8138
+ }
8139
+ const { pattern, timezone, anchor } = readSeriesRecurrence(instances);
8140
+ if (body.mode === "all") {
8141
+ return editAll({ context, body, instances, timezone, tableName });
8142
+ }
8143
+ return editFollowing({
8144
+ context,
8145
+ body,
8146
+ instances,
8147
+ pattern,
8148
+ timezone,
8149
+ anchor,
8150
+ tableName
8151
+ });
8152
+ }
8153
+ async function editAll(opts) {
8154
+ const { context, body, instances, timezone, tableName } = opts;
8155
+ const ledger = [];
8156
+ for (const instance of instances) {
8157
+ const occurrence = instance.occurrenceInstant ?? void 0;
8158
+ if (instance.resource.status === "cancelled") {
8159
+ ledger.push({
8160
+ id: instance.id,
8161
+ occurrence,
8162
+ action: "skipped-already-cancelled"
8163
+ });
8164
+ continue;
8165
+ }
8166
+ if (isSeriesInstanceOverridden(instance.resource)) {
8167
+ ledger.push({
8168
+ id: instance.id,
8169
+ occurrence,
8170
+ action: "skipped-overridden"
8171
+ });
8172
+ continue;
8173
+ }
8174
+ try {
8175
+ const updated = applyChanges(instance, body.changes, timezone);
8176
+ await updateAppointmentOperation({
8177
+ context,
8178
+ id: instance.id,
8179
+ body: updated,
8180
+ tableName
8181
+ });
8182
+ ledger.push({ id: instance.id, occurrence, action: "updated" });
8183
+ } catch (err) {
8184
+ if (err instanceof SeriesRequestError) throw err;
8185
+ ledger.push({
8186
+ id: instance.id,
8187
+ occurrence,
8188
+ action: "failed",
8189
+ diagnostics: String(err)
8190
+ });
8191
+ }
8192
+ }
8193
+ return { seriesId: body.seriesId, mode: "all", ledger };
8194
+ }
8195
+ async function editFollowing(opts) {
8196
+ const { context, body, instances, pattern, timezone, anchor, tableName } = opts;
8197
+ const boundaryMs = resolveFromOccurrence(instances, body.fromOccurrence);
8198
+ const kept = instances.filter((i) => !isAtOrAfter(i, boundaryMs));
8199
+ const replaced = instances.filter((i) => isAtOrAfter(i, boundaryMs));
8200
+ if (kept.length === 0) {
8201
+ throw new SeriesRequestError(
8202
+ 'fromOccurrence is the first occurrence of the series \u2014 there is nothing to split. Use mode="all" instead.'
8203
+ );
8204
+ }
8205
+ const lastKeptMs = Math.max(
8206
+ ...kept.map((i) => i.occurrenceInstant).filter((v) => v !== null).map((v) => Date.parse(v))
8207
+ );
8208
+ const truncatedPattern = {
8209
+ ...pattern,
8210
+ end: { kind: "until", until: new Date(lastKeptMs) }
8211
+ };
8212
+ const truncatedRule = buildRruleString(truncatedPattern);
8213
+ let tailPattern;
8214
+ if (body.newPattern !== void 0) {
8215
+ tailPattern = parseSeriesPattern(body.newPattern);
8216
+ } else if (pattern.end.kind === "count") {
8217
+ const producedBefore = expandOccurrences({
8218
+ pattern,
8219
+ seriesStart: anchor,
8220
+ timezone,
8221
+ window: { from: anchor, to: new Date(boundaryMs - 1) }
8222
+ }).length;
8223
+ const remaining = pattern.end.count - producedBefore;
8224
+ if (remaining < 1) {
8225
+ throw new SeriesRequestError(
8226
+ "The original series has no occurrences remaining at fromOccurrence; nothing to regenerate."
8227
+ );
8228
+ }
8229
+ tailPattern = { ...pattern, end: { kind: "count", count: remaining } };
8230
+ } else {
8231
+ tailPattern = pattern;
8232
+ }
8233
+ const boundaryInstant = new Date(boundaryMs).toISOString();
8234
+ const newAnchor = body.changes.startTime !== void 0 ? deriveWallClockStart(
8235
+ boundaryInstant,
8236
+ timezone,
8237
+ body.changes.startTime.localTime
8238
+ ) : new Date(boundaryMs);
8239
+ const tailOccurrences = expandSeries({
8240
+ pattern: tailPattern,
8241
+ anchor: newAnchor,
8242
+ timezone
8243
+ });
8244
+ const templateSource = replaced[0];
8245
+ const edited = applyChanges(
8246
+ { ...templateSource, occurrenceInstant: boundaryInstant },
8247
+ body.changes,
8248
+ timezone
8249
+ );
8250
+ const template = buildTailTemplate(edited);
8251
+ const durationMinutes = body.changes.startTime?.durationMinutes ?? templateSource.resource.minutesDuration ?? defaultDurationMinutes(templateSource.resource);
8252
+ const newSeriesId = randomUUID();
8253
+ const newRrule = buildRruleString(tailPattern);
8254
+ const tailInstances = buildSeriesInstances({
8255
+ base: template,
8256
+ occurrences: tailOccurrences,
8257
+ durationMinutes,
8258
+ seriesId: newSeriesId,
8259
+ rrule: newRrule,
8260
+ timezone,
8261
+ anchor: newAnchor
8262
+ });
8263
+ const ledger = [];
8264
+ for (const instance of kept) {
8265
+ try {
8266
+ await updateAppointmentOperation({
8267
+ context,
8268
+ id: instance.id,
8269
+ body: withTruncatedRule(instance.resource, truncatedRule),
8270
+ tableName
8271
+ });
8272
+ ledger.push({
8273
+ id: instance.id,
8274
+ occurrence: instance.occurrenceInstant ?? void 0,
8275
+ action: "rule-truncated"
8276
+ });
8277
+ } catch (err) {
8278
+ ledger.push({
8279
+ id: instance.id,
8280
+ occurrence: instance.occurrenceInstant ?? void 0,
8281
+ action: "failed",
8282
+ diagnostics: String(err)
8283
+ });
8284
+ }
8285
+ }
8286
+ for (let i = 0; i < tailInstances.length; i++) {
8287
+ const occurrence = tailOccurrences[i].toISOString();
8288
+ try {
8289
+ const created = await createAppointmentOperation({
8290
+ context,
8291
+ body: tailInstances[i],
8292
+ tableName
8293
+ });
8294
+ ledger.push({ id: created.id, occurrence, action: "created" });
8295
+ } catch (err) {
8296
+ ledger.push({ occurrence, action: "failed", diagnostics: String(err) });
8297
+ }
8298
+ }
8299
+ for (const instance of replaced) {
8300
+ try {
8301
+ await deleteAppointmentOperation({ context, id: instance.id, tableName });
8302
+ ledger.push({
8303
+ id: instance.id,
8304
+ occurrence: instance.occurrenceInstant ?? void 0,
8305
+ action: "deleted"
8306
+ });
8307
+ } catch (err) {
8308
+ ledger.push({
8309
+ id: instance.id,
8310
+ occurrence: instance.occurrenceInstant ?? void 0,
8311
+ action: "failed",
8312
+ diagnostics: String(err)
8313
+ });
8314
+ }
8315
+ }
8316
+ return {
8317
+ seriesId: body.seriesId,
8318
+ mode: "following",
8319
+ newSeriesId,
8320
+ truncatedRule,
8321
+ newRrule,
8322
+ ledger
8323
+ };
8324
+ }
8325
+ function defaultDurationMinutes(resource) {
8326
+ const start = Date.parse(resource.start ?? "");
8327
+ const end = Date.parse(resource.end ?? "");
8328
+ if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) {
8329
+ return Math.round((end - start) / 6e4);
8330
+ }
8331
+ throw new SeriesRequestError(
8332
+ "Cannot derive the tail's duration: supply changes.startTime.durationMinutes (the template instance has no usable minutesDuration or start/end)."
8333
+ );
8334
+ }
8335
+
8336
+ // src/data/rest-api/routes/data/appointment/appointment-series-routes.ts
8337
+ function sendOperationOutcome(res, status, code, diagnostics) {
8338
+ return res.status(status).json({
8339
+ resourceType: "OperationOutcome",
8340
+ issue: [{ severity: "error", code, diagnostics }]
8341
+ });
8342
+ }
8343
+ function sendSeriesError(res, err, logContext) {
8344
+ if (err instanceof SeriesRequestError) {
8345
+ return sendOperationOutcome(res, 400, "invalid", err.message);
8346
+ }
8347
+ if (err instanceof SeriesNotFoundError) {
8348
+ return sendOperationOutcome(res, 404, "not-found", err.message);
8349
+ }
8350
+ return sendOperationOutcome500(res, err, logContext);
8351
+ }
8352
+ async function createSeriesRoute(req, res) {
8353
+ const bodyResult = requireJsonBodyAs(req, res);
8354
+ if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
8355
+ try {
8356
+ const result = await createSeriesOperation({
8357
+ context: req.openhiContext,
8358
+ body: bodyResult.body
8359
+ });
8360
+ return res.status(200).json(result);
8361
+ } catch (err) {
8362
+ return sendSeriesError(res, err, "POST /Appointment/$create-series error:");
8363
+ }
8364
+ }
8365
+ async function editSeriesRoute(req, res) {
8366
+ const bodyResult = requireJsonBodyAs(req, res);
8367
+ if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
8368
+ try {
8369
+ const result = await editSeriesOperation({
8370
+ context: req.openhiContext,
8371
+ body: bodyResult.body
8372
+ });
8373
+ return res.status(200).json(result);
8374
+ } catch (err) {
8375
+ return sendSeriesError(res, err, "POST /Appointment/$edit-series error:");
8376
+ }
8377
+ }
8378
+ async function cancelSeriesRoute(req, res) {
8379
+ const bodyResult = requireJsonBodyAs(req, res);
8380
+ if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
8381
+ try {
8382
+ const result = await cancelSeriesOperation({
8383
+ context: req.openhiContext,
8384
+ body: bodyResult.body
8385
+ });
8386
+ return res.status(200).json(result);
8387
+ } catch (err) {
8388
+ return sendSeriesError(res, err, "POST /Appointment/$cancel-series error:");
8389
+ }
8390
+ }
8391
+
7383
8392
  // src/data/rest-api/routes/data/appointment/appointment-update-route.ts
7384
8393
  async function updateAppointmentRoute(req, res) {
7385
8394
  const bodyResult = requireJsonBodyAs(req, res);
@@ -7414,6 +8423,9 @@ var router14 = express14.Router();
7414
8423
  router14.get("/", listAppointmentsRoute);
7415
8424
  router14.get("/:id", getAppointmentByIdRoute);
7416
8425
  router14.post("/", createAppointmentRoute);
8426
+ router14.post("/$create-series", createSeriesRoute);
8427
+ router14.post("/$edit-series", editSeriesRoute);
8428
+ router14.post("/$cancel-series", cancelSeriesRoute);
7417
8429
  router14.put("/:id", updateAppointmentRoute);
7418
8430
  router14.delete("/:id", deleteAppointmentRoute);
7419
8431