@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.
@@ -12823,6 +12823,338 @@ async function listAppointmentsRoute(req, res) {
12823
12823
  }
12824
12824
  }
12825
12825
 
12826
+ // src/data/recurrence/constants.ts
12827
+ var import_types25 = require("@openhi/types");
12828
+
12829
+ // src/data/recurrence/rrule.ts
12830
+ var import_tz = require("@date-fns/tz");
12831
+ var import_date_fns = require("date-fns");
12832
+ var FREQ_TO_RRULE = {
12833
+ daily: "DAILY",
12834
+ weekly: "WEEKLY",
12835
+ monthly: "MONTHLY"
12836
+ };
12837
+ var RRULE_TO_FREQ = {
12838
+ DAILY: "daily",
12839
+ WEEKLY: "weekly",
12840
+ MONTHLY: "monthly"
12841
+ };
12842
+ var WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
12843
+ var WEEKDAY_OFFSET_FROM_MONDAY = {
12844
+ MO: 0,
12845
+ TU: 1,
12846
+ WE: 2,
12847
+ TH: 3,
12848
+ FR: 4,
12849
+ SA: 5,
12850
+ SU: 6
12851
+ };
12852
+ var JS_DAY_TO_WEEKDAY = {
12853
+ 0: "SU",
12854
+ 1: "MO",
12855
+ 2: "TU",
12856
+ 3: "WE",
12857
+ 4: "TH",
12858
+ 5: "FR",
12859
+ 6: "SA"
12860
+ };
12861
+ var DEFAULT_MAX_COUNT = 1e3;
12862
+ function pad(value, length) {
12863
+ return String(value).padStart(length, "0");
12864
+ }
12865
+ function formatRruleUtc(date) {
12866
+ 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`;
12867
+ }
12868
+ function parseRruleUtc(value) {
12869
+ const match = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
12870
+ if (!match) {
12871
+ throw new Error(`Unsupported RRULE UNTIL value: ${value}`);
12872
+ }
12873
+ const [, y, mo, d, h, mi, s] = match;
12874
+ return new Date(
12875
+ Date.UTC(
12876
+ Number(y),
12877
+ Number(mo) - 1,
12878
+ Number(d),
12879
+ Number(h),
12880
+ Number(mi),
12881
+ Number(s)
12882
+ )
12883
+ );
12884
+ }
12885
+ function buildRruleString(pattern) {
12886
+ const parts = [`FREQ=${FREQ_TO_RRULE[pattern.frequency]}`];
12887
+ if (pattern.interval > 1) {
12888
+ parts.push(`INTERVAL=${pattern.interval}`);
12889
+ }
12890
+ if (pattern.frequency === "weekly" && pattern.byWeekday?.length) {
12891
+ const ordered = WEEKDAYS.filter((day) => pattern.byWeekday?.includes(day));
12892
+ parts.push(`BYDAY=${ordered.join(",")}`);
12893
+ }
12894
+ if (pattern.end.kind === "count") {
12895
+ parts.push(`COUNT=${pattern.end.count}`);
12896
+ } else if (pattern.end.kind === "until") {
12897
+ parts.push(`UNTIL=${formatRruleUtc(pattern.end.until)}`);
12898
+ }
12899
+ return parts.join(";");
12900
+ }
12901
+ var SUPPORTED_RRULE_KEYS = /* @__PURE__ */ new Set([
12902
+ "FREQ",
12903
+ "INTERVAL",
12904
+ "BYDAY",
12905
+ "COUNT",
12906
+ "UNTIL"
12907
+ ]);
12908
+ var WEEKDAY_SET = new Set(WEEKDAYS);
12909
+ function parsePositiveInt(value, key) {
12910
+ const parsed = Number(value);
12911
+ if (!Number.isInteger(parsed) || parsed < 1) {
12912
+ throw new Error(`Invalid RRULE ${key}: ${value}`);
12913
+ }
12914
+ return parsed;
12915
+ }
12916
+ function parseRruleString(rrule) {
12917
+ const map = /* @__PURE__ */ new Map();
12918
+ for (const segment of rrule.split(";")) {
12919
+ const [key, value] = segment.split("=");
12920
+ if (!key || !value) continue;
12921
+ const normalizedKey = key.trim().toUpperCase();
12922
+ if (!SUPPORTED_RRULE_KEYS.has(normalizedKey)) {
12923
+ throw new Error(`Unsupported RRULE part: ${key}`);
12924
+ }
12925
+ map.set(normalizedKey, value.trim());
12926
+ }
12927
+ const freqRaw = map.get("FREQ");
12928
+ const frequency = freqRaw ? RRULE_TO_FREQ[freqRaw.toUpperCase()] : void 0;
12929
+ if (!frequency) {
12930
+ throw new Error(`Unsupported RRULE FREQ: ${freqRaw ?? "(missing)"}`);
12931
+ }
12932
+ const interval = map.has("INTERVAL") ? parsePositiveInt(map.get("INTERVAL"), "INTERVAL") : 1;
12933
+ let byWeekday;
12934
+ const byDay = map.get("BYDAY");
12935
+ if (byDay) {
12936
+ byWeekday = byDay.split(",").map((code) => {
12937
+ const normalized = code.trim().toUpperCase();
12938
+ if (!WEEKDAY_SET.has(normalized)) {
12939
+ throw new Error(`Unsupported RRULE BYDAY value: ${code}`);
12940
+ }
12941
+ return normalized;
12942
+ });
12943
+ }
12944
+ let end = { kind: "open" };
12945
+ if (map.has("COUNT")) {
12946
+ end = {
12947
+ kind: "count",
12948
+ count: parsePositiveInt(map.get("COUNT"), "COUNT")
12949
+ };
12950
+ } else if (map.has("UNTIL")) {
12951
+ end = { kind: "until", until: parseRruleUtc(map.get("UNTIL")) };
12952
+ }
12953
+ return { frequency, interval, byWeekday, end };
12954
+ }
12955
+ function setTimeOfDay(day, source) {
12956
+ return (0, import_date_fns.setMilliseconds)(
12957
+ (0, import_date_fns.setSeconds)((0, import_date_fns.setMinutes)((0, import_date_fns.setHours)(day, source.h), source.m), source.s),
12958
+ source.ms
12959
+ );
12960
+ }
12961
+ function expandOccurrences(args) {
12962
+ const { pattern, seriesStart, timezone, window } = args;
12963
+ const maxCount = args.maxCount ?? DEFAULT_MAX_COUNT;
12964
+ const startZ = new import_tz.TZDate(seriesStart.getTime(), timezone);
12965
+ const timeOfDay = {
12966
+ h: startZ.getHours(),
12967
+ m: startZ.getMinutes(),
12968
+ s: startZ.getSeconds(),
12969
+ ms: startZ.getMilliseconds()
12970
+ };
12971
+ const startMs = seriesStart.getTime();
12972
+ const fromMs = window.from.getTime();
12973
+ const toMs = window.to.getTime();
12974
+ const untilMs = pattern.end.kind === "until" ? pattern.end.until.getTime() : Infinity;
12975
+ const countLimit = pattern.end.kind === "count" ? pattern.end.count : Infinity;
12976
+ const results = [];
12977
+ let produced = 0;
12978
+ const accept = (candidate) => {
12979
+ const ms = candidate.getTime();
12980
+ if (ms < startMs) return true;
12981
+ if (ms > untilMs) return false;
12982
+ if (produced >= countLimit) return false;
12983
+ produced += 1;
12984
+ if (ms > toMs) return false;
12985
+ if (ms >= fromMs) results.push(new Date(ms));
12986
+ return true;
12987
+ };
12988
+ let examined = 0;
12989
+ const interval = Math.max(1, pattern.interval);
12990
+ let finished = false;
12991
+ if (pattern.frequency === "daily") {
12992
+ for (let i = 0; examined <= maxCount; i += 1, examined += 1) {
12993
+ if (!accept((0, import_date_fns.addDays)(startZ, i * interval))) {
12994
+ finished = true;
12995
+ break;
12996
+ }
12997
+ }
12998
+ } else if (pattern.frequency === "monthly") {
12999
+ for (let i = 0; examined <= maxCount; i += 1, examined += 1) {
13000
+ if (!accept((0, import_date_fns.addMonths)(startZ, i * interval))) {
13001
+ finished = true;
13002
+ break;
13003
+ }
13004
+ }
13005
+ } else {
13006
+ const days = pattern.byWeekday?.length ? pattern.byWeekday : [JS_DAY_TO_WEEKDAY[startZ.getDay()]];
13007
+ const offsets = WEEKDAYS.filter((day) => days.includes(day)).map(
13008
+ (day) => WEEKDAY_OFFSET_FROM_MONDAY[day]
13009
+ );
13010
+ const weekAnchor = (0, import_date_fns.startOfWeek)(startZ, { weekStartsOn: 1 });
13011
+ for (let w = 0; !finished && examined <= maxCount; w += interval) {
13012
+ const weekStart = (0, import_date_fns.addWeeks)(weekAnchor, w);
13013
+ for (const offset of offsets) {
13014
+ examined += 1;
13015
+ const occurrence = setTimeOfDay((0, import_date_fns.addDays)(weekStart, offset), timeOfDay);
13016
+ if (!accept(occurrence)) {
13017
+ finished = true;
13018
+ break;
13019
+ }
13020
+ }
13021
+ }
13022
+ }
13023
+ if (!finished) {
13024
+ throw new Error(
13025
+ `Recurrence expansion hit the ${maxCount}-candidate safety cap before reaching the end of the window. Raise maxCount or narrow the window.`
13026
+ );
13027
+ }
13028
+ return results;
13029
+ }
13030
+
13031
+ // src/data/recurrence/seriesInstances.ts
13032
+ var import_date_fns2 = require("date-fns");
13033
+
13034
+ // src/data/recurrence/membership.ts
13035
+ var import_types26 = require("@openhi/types");
13036
+
13037
+ // src/data/recurrence/seriesInstances.ts
13038
+ function buildSeriesInstances(args) {
13039
+ const {
13040
+ base,
13041
+ occurrences,
13042
+ durationMinutes,
13043
+ seriesId,
13044
+ rrule,
13045
+ timezone,
13046
+ anchor
13047
+ } = args;
13048
+ const seriesIdentifier = (0, import_types26.buildSeriesIdentifier)(seriesId);
13049
+ const seriesExtensions = [
13050
+ (0, import_types26.buildRecurrenceRuleExtension)(rrule),
13051
+ (0, import_types26.buildRecurrenceTimezoneExtension)(timezone),
13052
+ (0, import_types26.buildRecurrenceAnchorExtension)(anchor)
13053
+ ];
13054
+ const baseIdentifiers = base.identifier ?? [];
13055
+ const baseExtensions = base.extension ?? [];
13056
+ return occurrences.map((start) => ({
13057
+ ...base,
13058
+ start: start.toISOString(),
13059
+ end: (0, import_date_fns2.addMinutes)(start, durationMinutes).toISOString(),
13060
+ minutesDuration: durationMinutes,
13061
+ identifier: [
13062
+ ...baseIdentifiers,
13063
+ seriesIdentifier,
13064
+ (0, import_types26.buildOccurrenceIdentifier)(seriesId, start)
13065
+ ],
13066
+ extension: [...baseExtensions, ...seriesExtensions]
13067
+ }));
13068
+ }
13069
+
13070
+ // src/data/operations/data/appointment/appointment-series-common.ts
13071
+ var SeriesRequestError = class extends Error {
13072
+ };
13073
+ var SeriesNotFoundError = class extends Error {
13074
+ };
13075
+ var LOCATE_LIMIT = 500;
13076
+ async function locateSeriesInstances(opts) {
13077
+ const { context, seriesId, tableName } = opts;
13078
+ const found = await genericSearchOperation({
13079
+ resourceType: "Appointment",
13080
+ tenantId: context.tenantId,
13081
+ workspaceId: context.workspaceId,
13082
+ query: { identifier: `${import_types25.APPOINTMENT_SERIES_SYSTEM}|${seriesId}` },
13083
+ resolver: defaultSearchParameterResolver,
13084
+ limit: LOCATE_LIMIT
13085
+ });
13086
+ const instances = [];
13087
+ for (const entry of found.entries) {
13088
+ let resource;
13089
+ try {
13090
+ const hydrated = await getAppointmentByIdOperation({
13091
+ context,
13092
+ id: entry.id,
13093
+ tableName
13094
+ });
13095
+ resource = hydrated.resource;
13096
+ } catch {
13097
+ continue;
13098
+ }
13099
+ instances.push({
13100
+ id: entry.id,
13101
+ resource,
13102
+ occurrenceInstant: safeOccurrenceInstant(resource)
13103
+ });
13104
+ }
13105
+ instances.sort((a, b) => {
13106
+ if (a.occurrenceInstant === null && b.occurrenceInstant === null) {
13107
+ return a.id.localeCompare(b.id);
13108
+ }
13109
+ if (a.occurrenceInstant === null) return 1;
13110
+ if (b.occurrenceInstant === null) return -1;
13111
+ return Date.parse(a.occurrenceInstant) - Date.parse(b.occurrenceInstant) || a.id.localeCompare(b.id);
13112
+ });
13113
+ return instances;
13114
+ }
13115
+ function safeOccurrenceInstant(resource) {
13116
+ try {
13117
+ return (0, import_types26.getOccurrenceInstant)(resource);
13118
+ } catch {
13119
+ return null;
13120
+ }
13121
+ }
13122
+ function resolveFromOccurrence(instances, fromOccurrence) {
13123
+ const fromMs = Date.parse(fromOccurrence);
13124
+ if (Number.isNaN(fromMs)) {
13125
+ throw new SeriesRequestError(
13126
+ `fromOccurrence is not a valid ISO 8601 instant: "${fromOccurrence}".`
13127
+ );
13128
+ }
13129
+ const match = instances.some(
13130
+ (i) => i.occurrenceInstant !== null && Date.parse(i.occurrenceInstant) === fromMs
13131
+ );
13132
+ if (!match) {
13133
+ throw new SeriesRequestError(
13134
+ `fromOccurrence "${fromOccurrence}" does not match any occurrence key in the series.`
13135
+ );
13136
+ }
13137
+ return fromMs;
13138
+ }
13139
+ function isAtOrAfter(instance, boundaryMs) {
13140
+ return instance.occurrenceInstant !== null && Date.parse(instance.occurrenceInstant) >= boundaryMs;
13141
+ }
13142
+ function validateSeriesSelector(body) {
13143
+ if (typeof body.seriesId !== "string" || body.seriesId.length === 0) {
13144
+ throw new SeriesRequestError("seriesId is required.");
13145
+ }
13146
+ if (body.mode !== "following" && body.mode !== "all") {
13147
+ throw new SeriesRequestError(
13148
+ `mode must be "following" or "all"; received "${String(body.mode)}".`
13149
+ );
13150
+ }
13151
+ if (body.mode === "following" && !body.fromOccurrence) {
13152
+ throw new SeriesRequestError(
13153
+ 'fromOccurrence is required when mode is "following".'
13154
+ );
13155
+ }
13156
+ }
13157
+
12826
13158
  // src/data/operations/data/appointment/appointment-update-operation.ts
12827
13159
  async function updateAppointmentOperation(params) {
12828
13160
  const { context, id, body, tableName } = params;
@@ -12847,6 +13179,647 @@ async function updateAppointmentOperation(params) {
12847
13179
  );
12848
13180
  }
12849
13181
 
13182
+ // src/data/operations/data/appointment/appointment-cancel-series-operation.ts
13183
+ async function cancelSeriesOperation(params) {
13184
+ const { context, body, tableName } = params;
13185
+ validateSeriesSelector(body);
13186
+ const instances = await locateSeriesInstances({
13187
+ context,
13188
+ seriesId: body.seriesId,
13189
+ tableName
13190
+ });
13191
+ if (instances.length === 0) {
13192
+ throw new SeriesNotFoundError(
13193
+ `No Appointment instances found for series "${body.seriesId}".`
13194
+ );
13195
+ }
13196
+ let targets;
13197
+ if (body.mode === "following") {
13198
+ const boundaryMs = resolveFromOccurrence(instances, body.fromOccurrence);
13199
+ targets = instances.filter((i) => isAtOrAfter(i, boundaryMs));
13200
+ } else {
13201
+ targets = [...instances];
13202
+ }
13203
+ const ledger = [];
13204
+ for (const instance of targets) {
13205
+ const occurrence = instance.occurrenceInstant ?? void 0;
13206
+ if (instance.resource.status === "cancelled") {
13207
+ ledger.push({
13208
+ id: instance.id,
13209
+ occurrence,
13210
+ action: "skipped-already-cancelled"
13211
+ });
13212
+ continue;
13213
+ }
13214
+ try {
13215
+ const cancelled = {
13216
+ ...instance.resource,
13217
+ status: "cancelled"
13218
+ };
13219
+ await updateAppointmentOperation({
13220
+ context,
13221
+ id: instance.id,
13222
+ body: cancelled,
13223
+ tableName
13224
+ });
13225
+ ledger.push({ id: instance.id, occurrence, action: "cancelled" });
13226
+ } catch (err) {
13227
+ ledger.push({
13228
+ id: instance.id,
13229
+ occurrence,
13230
+ action: "failed",
13231
+ diagnostics: String(err)
13232
+ });
13233
+ }
13234
+ }
13235
+ return { seriesId: body.seriesId, mode: body.mode, ledger };
13236
+ }
13237
+
13238
+ // src/data/operations/data/appointment/appointment-create-series-operation.ts
13239
+ var SERIES_MAX_OCCURRENCES = 400;
13240
+ var FREQUENCIES = /* @__PURE__ */ new Set(["daily", "weekly", "monthly"]);
13241
+ var WEEKDAYS2 = /* @__PURE__ */ new Set(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]);
13242
+ function parseSeriesPattern(pattern) {
13243
+ if (!FREQUENCIES.has(pattern.frequency)) {
13244
+ throw new SeriesRequestError(
13245
+ `Unsupported pattern.frequency "${pattern.frequency}"; expected daily | weekly | monthly.`
13246
+ );
13247
+ }
13248
+ if (!Number.isInteger(pattern.interval) || pattern.interval < 1) {
13249
+ throw new SeriesRequestError(
13250
+ `pattern.interval must be an integer >= 1; received ${pattern.interval}.`
13251
+ );
13252
+ }
13253
+ let byWeekday;
13254
+ if (pattern.byWeekday !== void 0) {
13255
+ for (const day of pattern.byWeekday) {
13256
+ if (!WEEKDAYS2.has(day)) {
13257
+ throw new SeriesRequestError(
13258
+ `Unsupported pattern.byWeekday value "${day}".`
13259
+ );
13260
+ }
13261
+ }
13262
+ byWeekday = [...pattern.byWeekday];
13263
+ }
13264
+ const end = pattern.end;
13265
+ if (end.kind === "count") {
13266
+ if (!Number.isInteger(end.count) || end.count < 1) {
13267
+ throw new SeriesRequestError(
13268
+ `pattern.end.count must be an integer >= 1; received ${end.count}.`
13269
+ );
13270
+ }
13271
+ return {
13272
+ frequency: pattern.frequency,
13273
+ interval: pattern.interval,
13274
+ byWeekday,
13275
+ end: { kind: "count", count: end.count }
13276
+ };
13277
+ }
13278
+ if (end.kind === "until") {
13279
+ const until = new Date(end.until ?? "");
13280
+ if (Number.isNaN(until.getTime())) {
13281
+ throw new SeriesRequestError(
13282
+ `pattern.end.until is not a valid ISO 8601 instant: "${end.until}".`
13283
+ );
13284
+ }
13285
+ return {
13286
+ frequency: pattern.frequency,
13287
+ interval: pattern.interval,
13288
+ byWeekday,
13289
+ end: { kind: "until", until }
13290
+ };
13291
+ }
13292
+ throw new SeriesRequestError(
13293
+ `Unsupported pattern.end.kind "${end.kind}"; v1 accepts "count" or "until" only (open-ended series are not supported).`
13294
+ );
13295
+ }
13296
+ var WINDOW_END = /* @__PURE__ */ new Date("9999-12-31T00:00:00Z");
13297
+ function expandSeries(opts) {
13298
+ let occurrences;
13299
+ try {
13300
+ occurrences = expandOccurrences({
13301
+ pattern: opts.pattern,
13302
+ seriesStart: opts.anchor,
13303
+ timezone: opts.timezone,
13304
+ window: { from: opts.anchor, to: WINDOW_END },
13305
+ // The cap throws (never silently truncates); +1 distinguishes
13306
+ // "exactly at the ceiling" from "over it".
13307
+ maxCount: SERIES_MAX_OCCURRENCES + 1
13308
+ });
13309
+ } catch (err) {
13310
+ throw new SeriesRequestError(
13311
+ `Series expansion exceeded the ${SERIES_MAX_OCCURRENCES}-occurrence server ceiling: ${String(err)}`
13312
+ );
13313
+ }
13314
+ if (occurrences.length > SERIES_MAX_OCCURRENCES) {
13315
+ throw new SeriesRequestError(
13316
+ `Series expands to ${occurrences.length} occurrences; the server ceiling is ${SERIES_MAX_OCCURRENCES}.`
13317
+ );
13318
+ }
13319
+ return occurrences;
13320
+ }
13321
+ async function createSeriesOperation(params) {
13322
+ const { context, body, tableName } = params;
13323
+ if (typeof body.seriesId !== "string" || body.seriesId.length === 0) {
13324
+ throw new SeriesRequestError("seriesId is required.");
13325
+ }
13326
+ if (body.template === void 0 || typeof body.template !== "object") {
13327
+ throw new SeriesRequestError("template is required.");
13328
+ }
13329
+ if (!Number.isInteger(body.durationMinutes) || body.durationMinutes < 1) {
13330
+ throw new SeriesRequestError(
13331
+ `durationMinutes must be an integer >= 1; received ${body.durationMinutes}.`
13332
+ );
13333
+ }
13334
+ if (typeof body.timezone !== "string" || body.timezone.length === 0) {
13335
+ throw new SeriesRequestError("timezone is required (IANA zone).");
13336
+ }
13337
+ const anchor = new Date(body.anchor ?? "");
13338
+ if (Number.isNaN(anchor.getTime())) {
13339
+ throw new SeriesRequestError(
13340
+ `anchor is not a valid ISO 8601 instant: "${body.anchor}".`
13341
+ );
13342
+ }
13343
+ const pattern = parseSeriesPattern(
13344
+ body.pattern ?? {}
13345
+ );
13346
+ const rrule = buildRruleString(pattern);
13347
+ const occurrences = expandSeries({
13348
+ pattern,
13349
+ anchor,
13350
+ timezone: body.timezone
13351
+ });
13352
+ if (body.dryRun === true) {
13353
+ return {
13354
+ seriesId: body.seriesId,
13355
+ rrule,
13356
+ occurrences: occurrences.map((o) => o.toISOString())
13357
+ };
13358
+ }
13359
+ const existing = await locateSeriesInstances({
13360
+ context,
13361
+ seriesId: body.seriesId,
13362
+ tableName
13363
+ });
13364
+ const existingInstants = new Set(
13365
+ existing.map((i) => i.occurrenceInstant).filter((v) => v !== null).map((v) => Date.parse(v))
13366
+ );
13367
+ const {
13368
+ id: _id,
13369
+ start: _start,
13370
+ end: _end,
13371
+ ...template
13372
+ } = body.template;
13373
+ const instances = buildSeriesInstances({
13374
+ base: template,
13375
+ occurrences,
13376
+ durationMinutes: body.durationMinutes,
13377
+ seriesId: body.seriesId,
13378
+ rrule,
13379
+ timezone: body.timezone,
13380
+ anchor
13381
+ });
13382
+ const ledger = [];
13383
+ for (let i = 0; i < instances.length; i++) {
13384
+ const occurrence = occurrences[i].toISOString();
13385
+ if (existingInstants.has(occurrences[i].getTime())) {
13386
+ ledger.push({ occurrence, action: "skipped-existing" });
13387
+ continue;
13388
+ }
13389
+ try {
13390
+ const created = await createAppointmentOperation({
13391
+ context,
13392
+ body: instances[i],
13393
+ tableName
13394
+ });
13395
+ ledger.push({ id: created.id, occurrence, action: "created" });
13396
+ } catch (err) {
13397
+ ledger.push({ occurrence, action: "failed", diagnostics: String(err) });
13398
+ }
13399
+ }
13400
+ return {
13401
+ seriesId: body.seriesId,
13402
+ rrule,
13403
+ occurrences: occurrences.map((o) => o.toISOString()),
13404
+ ledger
13405
+ };
13406
+ }
13407
+
13408
+ // src/data/operations/data/appointment/appointment-edit-series-operation.ts
13409
+ var import_node_crypto = require("crypto");
13410
+ var import_tz2 = require("@date-fns/tz");
13411
+ var import_date_fns3 = require("date-fns");
13412
+ var LOCAL_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
13413
+ function validateChanges(changes) {
13414
+ if (changes === void 0 || typeof changes !== "object") {
13415
+ throw new SeriesRequestError("changes is required.");
13416
+ }
13417
+ const named = changes.startTime !== void 0 || changes.practitioners !== void 0 || changes.patients !== void 0 || changes.appointmentType !== void 0 || changes.description !== void 0;
13418
+ if (!named) {
13419
+ throw new SeriesRequestError(
13420
+ "changes must name at least one of: startTime, practitioners, patients, appointmentType, description."
13421
+ );
13422
+ }
13423
+ if (changes.startTime !== void 0) {
13424
+ if (!LOCAL_TIME_PATTERN.test(changes.startTime.localTime ?? "")) {
13425
+ throw new SeriesRequestError(
13426
+ `changes.startTime.localTime must be wall-clock "HH:mm"; received "${changes.startTime.localTime}".`
13427
+ );
13428
+ }
13429
+ if (!Number.isInteger(changes.startTime.durationMinutes) || changes.startTime.durationMinutes < 1) {
13430
+ throw new SeriesRequestError(
13431
+ "changes.startTime.durationMinutes must be an integer >= 1."
13432
+ );
13433
+ }
13434
+ }
13435
+ validateRoleParticipants(changes.practitioners, "Practitioner/");
13436
+ validateRoleParticipants(changes.patients, "Patient/");
13437
+ }
13438
+ function validateRoleParticipants(participants, rolePrefix) {
13439
+ if (participants === void 0) return;
13440
+ for (const p of participants) {
13441
+ const reference = p.actor?.reference ?? "";
13442
+ if (!reference.startsWith(rolePrefix)) {
13443
+ throw new SeriesRequestError(
13444
+ `Every entry in the ${rolePrefix.slice(0, -1)} participant list must reference a ${rolePrefix.slice(0, -1)} (got "${reference}").`
13445
+ );
13446
+ }
13447
+ }
13448
+ }
13449
+ function replaceRoleParticipants(existing, rolePrefix, replacements) {
13450
+ const others = existing.filter(
13451
+ (p) => !(p.actor?.reference ?? "").startsWith(rolePrefix)
13452
+ );
13453
+ return [...others, ...replacements];
13454
+ }
13455
+ function deriveWallClockStart(occurrenceInstant, timezone, localTime) {
13456
+ const [hours, minutes] = localTime.split(":").map(Number);
13457
+ const onDate = new import_tz2.TZDate(Date.parse(occurrenceInstant), timezone);
13458
+ const adjusted = (0, import_date_fns3.setMilliseconds)(
13459
+ (0, import_date_fns3.setSeconds)((0, import_date_fns3.setMinutes)((0, import_date_fns3.setHours)(onDate, hours), minutes), 0),
13460
+ 0
13461
+ );
13462
+ return new Date(adjusted.getTime());
13463
+ }
13464
+ function applyChanges(instance, changes, timezone) {
13465
+ const resource = { ...instance.resource };
13466
+ if (changes.startTime !== void 0) {
13467
+ if (instance.occurrenceInstant === null) {
13468
+ throw new SeriesRequestError(
13469
+ `Instance ${instance.id} has no occurrence identifier; cannot re-derive its start time.`
13470
+ );
13471
+ }
13472
+ const start = deriveWallClockStart(
13473
+ instance.occurrenceInstant,
13474
+ timezone,
13475
+ changes.startTime.localTime
13476
+ );
13477
+ resource.start = start.toISOString();
13478
+ resource.end = (0, import_date_fns3.addMinutes)(
13479
+ start,
13480
+ changes.startTime.durationMinutes
13481
+ ).toISOString();
13482
+ resource.minutesDuration = changes.startTime.durationMinutes;
13483
+ }
13484
+ if (changes.practitioners !== void 0 || changes.patients !== void 0) {
13485
+ let participants = [...resource.participant ?? []];
13486
+ if (changes.practitioners !== void 0) {
13487
+ participants = replaceRoleParticipants(
13488
+ participants,
13489
+ "Practitioner/",
13490
+ changes.practitioners
13491
+ );
13492
+ }
13493
+ if (changes.patients !== void 0) {
13494
+ participants = replaceRoleParticipants(
13495
+ participants,
13496
+ "Patient/",
13497
+ changes.patients
13498
+ );
13499
+ }
13500
+ resource.participant = participants;
13501
+ }
13502
+ if (changes.appointmentType !== void 0) {
13503
+ resource.appointmentType = changes.appointmentType;
13504
+ }
13505
+ if (changes.description !== void 0) {
13506
+ resource.description = changes.description;
13507
+ }
13508
+ return resource;
13509
+ }
13510
+ function readSeriesRecurrence(instances) {
13511
+ for (const instance of instances) {
13512
+ const rule = (0, import_types26.getRecurrenceRule)(instance.resource);
13513
+ const timezone = (0, import_types26.getRecurrenceTimezone)(instance.resource);
13514
+ const anchor = (0, import_types26.getRecurrenceAnchor)(instance.resource);
13515
+ if (rule !== null && timezone !== null && anchor !== null) {
13516
+ return { pattern: parseRruleString(rule), timezone, anchor };
13517
+ }
13518
+ }
13519
+ throw new SeriesRequestError(
13520
+ "Series instances are missing the recurrence-rule / recurrence-timezone / recurrence-anchor extensions; the series cannot be edited as a series."
13521
+ );
13522
+ }
13523
+ function withTruncatedRule(resource, rule) {
13524
+ const extension = (resource.extension ?? []).map(
13525
+ (ext) => ext.url === import_types25.RECURRENCE_RULE_EXTENSION_URL ? { ...ext, valueString: rule } : ext
13526
+ );
13527
+ return { ...resource, extension };
13528
+ }
13529
+ function buildTailTemplate(resource) {
13530
+ const { id: _id, start: _start, end: _end, meta: _meta, ...rest } = resource;
13531
+ const identifier = (rest.identifier ?? []).filter(
13532
+ (i) => i.system !== import_types25.APPOINTMENT_SERIES_SYSTEM && i.system !== import_types25.APPOINTMENT_SERIES_OCCURRENCE_SYSTEM
13533
+ );
13534
+ const stripped = /* @__PURE__ */ new Set([
13535
+ import_types25.RECURRENCE_RULE_EXTENSION_URL,
13536
+ import_types25.RECURRENCE_TIMEZONE_EXTENSION_URL,
13537
+ import_types25.RECURRENCE_ANCHOR_EXTENSION_URL,
13538
+ import_types25.SERIES_INSTANCE_OVERRIDDEN_EXTENSION_URL
13539
+ ]);
13540
+ const extension = (rest.extension ?? []).filter(
13541
+ (e) => !stripped.has(e.url ?? "")
13542
+ );
13543
+ return {
13544
+ ...rest,
13545
+ identifier,
13546
+ extension,
13547
+ // A tail regenerated from a cancelled instance must not be born cancelled.
13548
+ status: rest.status === "cancelled" ? "booked" : rest.status
13549
+ };
13550
+ }
13551
+ async function editSeriesOperation(params) {
13552
+ const { context, body, tableName } = params;
13553
+ validateSeriesSelector(body);
13554
+ validateChanges(body.changes);
13555
+ if (body.newPattern !== void 0 && body.mode !== "following") {
13556
+ throw new SeriesRequestError(
13557
+ 'newPattern is only valid with mode="following".'
13558
+ );
13559
+ }
13560
+ const instances = await locateSeriesInstances({
13561
+ context,
13562
+ seriesId: body.seriesId,
13563
+ tableName
13564
+ });
13565
+ if (instances.length === 0) {
13566
+ throw new SeriesNotFoundError(
13567
+ `No Appointment instances found for series "${body.seriesId}".`
13568
+ );
13569
+ }
13570
+ const { pattern, timezone, anchor } = readSeriesRecurrence(instances);
13571
+ if (body.mode === "all") {
13572
+ return editAll({ context, body, instances, timezone, tableName });
13573
+ }
13574
+ return editFollowing({
13575
+ context,
13576
+ body,
13577
+ instances,
13578
+ pattern,
13579
+ timezone,
13580
+ anchor,
13581
+ tableName
13582
+ });
13583
+ }
13584
+ async function editAll(opts) {
13585
+ const { context, body, instances, timezone, tableName } = opts;
13586
+ const ledger = [];
13587
+ for (const instance of instances) {
13588
+ const occurrence = instance.occurrenceInstant ?? void 0;
13589
+ if (instance.resource.status === "cancelled") {
13590
+ ledger.push({
13591
+ id: instance.id,
13592
+ occurrence,
13593
+ action: "skipped-already-cancelled"
13594
+ });
13595
+ continue;
13596
+ }
13597
+ if ((0, import_types26.isSeriesInstanceOverridden)(instance.resource)) {
13598
+ ledger.push({
13599
+ id: instance.id,
13600
+ occurrence,
13601
+ action: "skipped-overridden"
13602
+ });
13603
+ continue;
13604
+ }
13605
+ try {
13606
+ const updated = applyChanges(instance, body.changes, timezone);
13607
+ await updateAppointmentOperation({
13608
+ context,
13609
+ id: instance.id,
13610
+ body: updated,
13611
+ tableName
13612
+ });
13613
+ ledger.push({ id: instance.id, occurrence, action: "updated" });
13614
+ } catch (err) {
13615
+ if (err instanceof SeriesRequestError) throw err;
13616
+ ledger.push({
13617
+ id: instance.id,
13618
+ occurrence,
13619
+ action: "failed",
13620
+ diagnostics: String(err)
13621
+ });
13622
+ }
13623
+ }
13624
+ return { seriesId: body.seriesId, mode: "all", ledger };
13625
+ }
13626
+ async function editFollowing(opts) {
13627
+ const { context, body, instances, pattern, timezone, anchor, tableName } = opts;
13628
+ const boundaryMs = resolveFromOccurrence(instances, body.fromOccurrence);
13629
+ const kept = instances.filter((i) => !isAtOrAfter(i, boundaryMs));
13630
+ const replaced = instances.filter((i) => isAtOrAfter(i, boundaryMs));
13631
+ if (kept.length === 0) {
13632
+ throw new SeriesRequestError(
13633
+ 'fromOccurrence is the first occurrence of the series \u2014 there is nothing to split. Use mode="all" instead.'
13634
+ );
13635
+ }
13636
+ const lastKeptMs = Math.max(
13637
+ ...kept.map((i) => i.occurrenceInstant).filter((v) => v !== null).map((v) => Date.parse(v))
13638
+ );
13639
+ const truncatedPattern = {
13640
+ ...pattern,
13641
+ end: { kind: "until", until: new Date(lastKeptMs) }
13642
+ };
13643
+ const truncatedRule = buildRruleString(truncatedPattern);
13644
+ let tailPattern;
13645
+ if (body.newPattern !== void 0) {
13646
+ tailPattern = parseSeriesPattern(body.newPattern);
13647
+ } else if (pattern.end.kind === "count") {
13648
+ const producedBefore = expandOccurrences({
13649
+ pattern,
13650
+ seriesStart: anchor,
13651
+ timezone,
13652
+ window: { from: anchor, to: new Date(boundaryMs - 1) }
13653
+ }).length;
13654
+ const remaining = pattern.end.count - producedBefore;
13655
+ if (remaining < 1) {
13656
+ throw new SeriesRequestError(
13657
+ "The original series has no occurrences remaining at fromOccurrence; nothing to regenerate."
13658
+ );
13659
+ }
13660
+ tailPattern = { ...pattern, end: { kind: "count", count: remaining } };
13661
+ } else {
13662
+ tailPattern = pattern;
13663
+ }
13664
+ const boundaryInstant = new Date(boundaryMs).toISOString();
13665
+ const newAnchor = body.changes.startTime !== void 0 ? deriveWallClockStart(
13666
+ boundaryInstant,
13667
+ timezone,
13668
+ body.changes.startTime.localTime
13669
+ ) : new Date(boundaryMs);
13670
+ const tailOccurrences = expandSeries({
13671
+ pattern: tailPattern,
13672
+ anchor: newAnchor,
13673
+ timezone
13674
+ });
13675
+ const templateSource = replaced[0];
13676
+ const edited = applyChanges(
13677
+ { ...templateSource, occurrenceInstant: boundaryInstant },
13678
+ body.changes,
13679
+ timezone
13680
+ );
13681
+ const template = buildTailTemplate(edited);
13682
+ const durationMinutes = body.changes.startTime?.durationMinutes ?? templateSource.resource.minutesDuration ?? defaultDurationMinutes(templateSource.resource);
13683
+ const newSeriesId = (0, import_node_crypto.randomUUID)();
13684
+ const newRrule = buildRruleString(tailPattern);
13685
+ const tailInstances = buildSeriesInstances({
13686
+ base: template,
13687
+ occurrences: tailOccurrences,
13688
+ durationMinutes,
13689
+ seriesId: newSeriesId,
13690
+ rrule: newRrule,
13691
+ timezone,
13692
+ anchor: newAnchor
13693
+ });
13694
+ const ledger = [];
13695
+ for (const instance of kept) {
13696
+ try {
13697
+ await updateAppointmentOperation({
13698
+ context,
13699
+ id: instance.id,
13700
+ body: withTruncatedRule(instance.resource, truncatedRule),
13701
+ tableName
13702
+ });
13703
+ ledger.push({
13704
+ id: instance.id,
13705
+ occurrence: instance.occurrenceInstant ?? void 0,
13706
+ action: "rule-truncated"
13707
+ });
13708
+ } catch (err) {
13709
+ ledger.push({
13710
+ id: instance.id,
13711
+ occurrence: instance.occurrenceInstant ?? void 0,
13712
+ action: "failed",
13713
+ diagnostics: String(err)
13714
+ });
13715
+ }
13716
+ }
13717
+ for (let i = 0; i < tailInstances.length; i++) {
13718
+ const occurrence = tailOccurrences[i].toISOString();
13719
+ try {
13720
+ const created = await createAppointmentOperation({
13721
+ context,
13722
+ body: tailInstances[i],
13723
+ tableName
13724
+ });
13725
+ ledger.push({ id: created.id, occurrence, action: "created" });
13726
+ } catch (err) {
13727
+ ledger.push({ occurrence, action: "failed", diagnostics: String(err) });
13728
+ }
13729
+ }
13730
+ for (const instance of replaced) {
13731
+ try {
13732
+ await deleteAppointmentOperation({ context, id: instance.id, tableName });
13733
+ ledger.push({
13734
+ id: instance.id,
13735
+ occurrence: instance.occurrenceInstant ?? void 0,
13736
+ action: "deleted"
13737
+ });
13738
+ } catch (err) {
13739
+ ledger.push({
13740
+ id: instance.id,
13741
+ occurrence: instance.occurrenceInstant ?? void 0,
13742
+ action: "failed",
13743
+ diagnostics: String(err)
13744
+ });
13745
+ }
13746
+ }
13747
+ return {
13748
+ seriesId: body.seriesId,
13749
+ mode: "following",
13750
+ newSeriesId,
13751
+ truncatedRule,
13752
+ newRrule,
13753
+ ledger
13754
+ };
13755
+ }
13756
+ function defaultDurationMinutes(resource) {
13757
+ const start = Date.parse(resource.start ?? "");
13758
+ const end = Date.parse(resource.end ?? "");
13759
+ if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) {
13760
+ return Math.round((end - start) / 6e4);
13761
+ }
13762
+ throw new SeriesRequestError(
13763
+ "Cannot derive the tail's duration: supply changes.startTime.durationMinutes (the template instance has no usable minutesDuration or start/end)."
13764
+ );
13765
+ }
13766
+
13767
+ // src/data/rest-api/routes/data/appointment/appointment-series-routes.ts
13768
+ function sendOperationOutcome(res, status, code, diagnostics) {
13769
+ return res.status(status).json({
13770
+ resourceType: "OperationOutcome",
13771
+ issue: [{ severity: "error", code, diagnostics }]
13772
+ });
13773
+ }
13774
+ function sendSeriesError(res, err, logContext) {
13775
+ if (err instanceof SeriesRequestError) {
13776
+ return sendOperationOutcome(res, 400, "invalid", err.message);
13777
+ }
13778
+ if (err instanceof SeriesNotFoundError) {
13779
+ return sendOperationOutcome(res, 404, "not-found", err.message);
13780
+ }
13781
+ return sendOperationOutcome500(res, err, logContext);
13782
+ }
13783
+ async function createSeriesRoute(req, res) {
13784
+ const bodyResult = requireJsonBodyAs(req, res);
13785
+ if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
13786
+ try {
13787
+ const result = await createSeriesOperation({
13788
+ context: req.openhiContext,
13789
+ body: bodyResult.body
13790
+ });
13791
+ return res.status(200).json(result);
13792
+ } catch (err) {
13793
+ return sendSeriesError(res, err, "POST /Appointment/$create-series error:");
13794
+ }
13795
+ }
13796
+ async function editSeriesRoute(req, res) {
13797
+ const bodyResult = requireJsonBodyAs(req, res);
13798
+ if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
13799
+ try {
13800
+ const result = await editSeriesOperation({
13801
+ context: req.openhiContext,
13802
+ body: bodyResult.body
13803
+ });
13804
+ return res.status(200).json(result);
13805
+ } catch (err) {
13806
+ return sendSeriesError(res, err, "POST /Appointment/$edit-series error:");
13807
+ }
13808
+ }
13809
+ async function cancelSeriesRoute(req, res) {
13810
+ const bodyResult = requireJsonBodyAs(req, res);
13811
+ if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
13812
+ try {
13813
+ const result = await cancelSeriesOperation({
13814
+ context: req.openhiContext,
13815
+ body: bodyResult.body
13816
+ });
13817
+ return res.status(200).json(result);
13818
+ } catch (err) {
13819
+ return sendSeriesError(res, err, "POST /Appointment/$cancel-series error:");
13820
+ }
13821
+ }
13822
+
12850
13823
  // src/data/rest-api/routes/data/appointment/appointment-update-route.ts
12851
13824
  async function updateAppointmentRoute(req, res) {
12852
13825
  const bodyResult = requireJsonBodyAs(req, res);
@@ -12881,6 +13854,9 @@ var router14 = import_express14.default.Router();
12881
13854
  router14.get("/", listAppointmentsRoute);
12882
13855
  router14.get("/:id", getAppointmentByIdRoute);
12883
13856
  router14.post("/", createAppointmentRoute);
13857
+ router14.post("/$create-series", createSeriesRoute);
13858
+ router14.post("/$edit-series", editSeriesRoute);
13859
+ router14.post("/$cancel-series", cancelSeriesRoute);
12884
13860
  router14.put("/:id", updateAppointmentRoute);
12885
13861
  router14.delete("/:id", deleteAppointmentRoute);
12886
13862