@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
|
|