@open-mercato/core 0.10.1-develop.7724.1.95e4e7d38e → 0.10.1-develop.7727.1.0b89c4f800

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.
Files changed (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/directory/commands/organizations.js +39 -0
  3. package/dist/modules/directory/commands/organizations.js.map +2 -2
  4. package/dist/modules/directory/lib/hierarchy.js +34 -2
  5. package/dist/modules/directory/lib/hierarchy.js.map +2 -2
  6. package/dist/modules/planner/api/availability-date-specific.js +2 -7
  7. package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
  8. package/dist/modules/planner/commands/availability-date-specific.js +14 -31
  9. package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
  10. package/dist/modules/planner/commands/availability-weekly.js +8 -22
  11. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  12. package/dist/modules/planner/lib/availabilityMerge.js +10 -18
  13. package/dist/modules/planner/lib/availabilityMerge.js.map +2 -2
  14. package/dist/modules/planner/lib/availabilityTimezone.js +161 -0
  15. package/dist/modules/planner/lib/availabilityTimezone.js.map +7 -0
  16. package/package.json +8 -7
  17. package/src/modules/directory/commands/organizations.ts +45 -0
  18. package/src/modules/directory/i18n/de.json +1 -0
  19. package/src/modules/directory/i18n/en.json +1 -0
  20. package/src/modules/directory/i18n/es.json +1 -0
  21. package/src/modules/directory/i18n/ko.json +1 -0
  22. package/src/modules/directory/i18n/pl.json +1 -0
  23. package/src/modules/directory/lib/hierarchy.ts +41 -2
  24. package/src/modules/planner/api/availability-date-specific.ts +5 -10
  25. package/src/modules/planner/commands/availability-date-specific.ts +14 -33
  26. package/src/modules/planner/commands/availability-weekly.ts +15 -24
  27. package/src/modules/planner/lib/availabilityMerge.ts +13 -19
  28. package/src/modules/planner/lib/availabilityTimezone.ts +242 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/planner/lib/availabilityMerge.ts"],
4
- "sourcesContent": ["type AvailabilityKind = 'availability' | 'unavailability'\n\nexport type AvailabilityRuleLike = {\n id?: string\n rrule: string\n timezone?: string\n exdates?: string[]\n kind?: AvailabilityKind\n note?: string | null\n}\n\nexport type AvailabilityRange = {\n start: Date\n end: Date\n}\n\nexport type AvailabilityWindow = {\n start: Date\n end: Date\n ruleId?: string\n}\n\ntype ParsedRule = {\n startAt: Date\n durationMinutes: number\n freq: 'DAILY' | 'WEEKLY'\n repeat: 'once' | 'daily' | 'weekly'\n count?: number\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000\nconst timezoneFormatters = new Map<string, Intl.DateTimeFormat>()\n\nfunction zonedParts(instant: Date, timezone: string): { year: number; month: number; day: number; hour: number; minute: number; second: number } {\n let formatter = timezoneFormatters.get(timezone)\n if (!formatter) {\n formatter = new Intl.DateTimeFormat('en-US', {\n timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',\n hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',\n })\n if (timezoneFormatters.size >= 64) timezoneFormatters.delete(timezoneFormatters.keys().next().value!)\n timezoneFormatters.set(timezone, formatter)\n }\n const parts = formatter.formatToParts(instant)\n const number = (kind: string) => Number(parts.find((part) => part.type === kind)?.value)\n return { year: number('year'), month: number('month'), day: number('day'), hour: number('hour'), minute: number('minute'), second: number('second') }\n}\n\nfunction zonedWallToInstant(wall: Date, timezone: string): Date | null {\n const target = wall.getTime()\n let candidate = target\n for (let attempt = 0; attempt < 4; attempt += 1) {\n const parts = zonedParts(new Date(candidate), timezone)\n const displayed = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second)\n const next = candidate + target - displayed\n if (next === candidate) return new Date(candidate)\n candidate = next\n }\n return null\n}\n\nfunction zonedStartOfDay(dayKey: string, timezone: string): Date | null {\n return zonedWallToInstant(new Date(`${dayKey}T00:00:00Z`), timezone)\n}\n\nfunction dayKeyForRule(instant: Date, timezone?: string): string {\n if (!timezone || timezone === 'UTC' || timezone === 'Etc/UTC' || timezone === 'Etc/GMT') return toDayKey(instant)\n const parts = zonedParts(instant, timezone)\n return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`\n}\n\nfunction parseRrule(rule: string): ParsedRule | null {\n const dtStartMatch = rule.match(/DTSTART[:=](\\d{8}T\\d{6}Z?)/)\n const durationMatch = rule.match(/DURATION:PT(?:(\\d+)H)?(?:(\\d+)M)?/)\n const freqMatch = rule.match(/FREQ=([A-Z]+)/)\n if (!dtStartMatch?.[1] || !durationMatch || !freqMatch?.[1]) return null\n const raw = dtStartMatch[1].replace(/Z$/, '')\n const parts = raw.match(/(\\d{4})(\\d{2})(\\d{2})T(\\d{2})(\\d{2})(\\d{2})/)\n if (!parts) return null\n const [, year, month, day, hour, minute, second] = parts\n const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`\n const startAt = new Date(iso)\n if (Number.isNaN(startAt.getTime())) return null\n\n const hours = durationMatch[1] ? Number(durationMatch[1]) : 0\n const minutes = durationMatch[2] ? Number(durationMatch[2]) : 0\n const durationMinutes = Math.max(1, hours * 60 + minutes)\n const freq = freqMatch[1]\n if (freq !== 'DAILY' && freq !== 'WEEKLY') return null\n\n const countMatch = rule.match(/COUNT=(\\d+)/)\n const count = countMatch?.[1] ? Number(countMatch[1]) : undefined\n const repeat = freq === 'WEEKLY'\n ? 'weekly'\n : freq === 'DAILY' && count === 1\n ? 'once'\n : 'daily'\n return { startAt, durationMinutes, freq, count, repeat }\n}\n\nfunction buildExdateSets(exdates?: string[]) {\n const dateOnly = new Set<string>()\n const dateTime = new Set<number>()\n ;(exdates ?? []).forEach((value) => {\n if (typeof value !== 'string') return\n const trimmed = value.trim()\n if (!trimmed) return\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) {\n dateOnly.add(trimmed)\n return\n }\n const parsed = new Date(trimmed)\n if (!Number.isNaN(parsed.getTime())) {\n dateTime.add(parsed.getTime())\n }\n })\n return { dateOnly, dateTime }\n}\n\nfunction shouldExcludeOccurrence(startAt: Date, exdates?: string[], timezone?: string): boolean {\n const { dateOnly, dateTime } = buildExdateSets(exdates)\n if (dateTime.has(startAt.getTime())) return true\n const dayKey = dayKeyForRule(startAt, timezone)\n return dateOnly.has(dayKey)\n}\n\nfunction toDayKey(value: Date): string {\n return value.toISOString().slice(0, 10)\n}\n\nfunction startOfDay(value: Date): Date {\n return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))\n}\n\nfunction expandZonedRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {\n const timezone = rule.timezone!\n const anchor = zonedParts(parsed.startAt, timezone)\n const anchorWall = Date.UTC(anchor.year, anchor.month - 1, anchor.day, anchor.hour, anchor.minute, anchor.second)\n const step = parsed.freq === 'WEEKLY' ? 7 * DAY_MS : DAY_MS\n const duration = parsed.durationMinutes * 60000\n const windows: AvailabilityWindow[] = []\n const rangeAnchor = zonedParts(range.start, timezone)\n const rangeStartWall = Date.UTC(rangeAnchor.year, rangeAnchor.month - 1, rangeAnchor.day, rangeAnchor.hour, rangeAnchor.minute, rangeAnchor.second)\n const rangeOffset = Math.floor((rangeStartWall - duration - DAY_MS - anchorWall) / step)\n const skipped = weeklyScheduleTemplate && parsed.freq === 'WEEKLY' && parsed.count === undefined ? rangeOffset : Math.max(0, rangeOffset)\n let wall = new Date(anchorWall + skipped * step)\n let remaining = (parsed.count ?? Number.POSITIVE_INFINITY) - skipped\n while (wall.getTime() <= range.end.getTime() + DAY_MS && remaining > 0) {\n const start = zonedWallToInstant(wall, timezone)\n if (start && !shouldExcludeOccurrence(start, rule.exdates, timezone)) {\n const end = new Date(start.getTime() + duration)\n if (end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id })\n }\n wall = new Date(wall.getTime() + step)\n remaining -= 1\n }\n return windows\n}\n\nfunction expandRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {\n const { startAt, durationMinutes, freq, count, repeat } = parsed\n if (repeat === 'once') {\n if (shouldExcludeOccurrence(startAt, rule.exdates, rule.timezone)) return []\n const dayKey = dayKeyForRule(startAt, rule.timezone)\n const start = rule.timezone ? zonedStartOfDay(dayKey, rule.timezone) : startOfDay(startAt)\n if (!start) return []\n const nextDay = new Date(new Date(`${dayKey}T00:00:00Z`).getTime() + DAY_MS).toISOString().slice(0, 10)\n const end = rule.timezone ? zonedStartOfDay(nextDay, rule.timezone) : new Date(start.getTime() + DAY_MS)\n if (!end) return []\n if (end <= range.start || start >= range.end) return []\n return [{ start, end, ruleId: rule.id }]\n }\n if (rule.timezone && !['UTC', 'Etc/UTC', 'Etc/GMT'].includes(rule.timezone)) {\n return expandZonedRule(rule, parsed, range, weeklyScheduleTemplate)\n }\n const durationMs = durationMinutes * 60000\n const windows: AvailabilityWindow[] = []\n const addWindow = (start: Date) => {\n if (shouldExcludeOccurrence(start, rule.exdates)) return\n const end = new Date(start.getTime() + durationMs)\n if (end <= range.start || start >= range.end) return\n windows.push({ start, end, ruleId: rule.id })\n }\n\n const step = freq === 'WEEKLY' ? 7 * DAY_MS : DAY_MS\n const rangeOffset = Math.floor((range.start.getTime() - durationMs - startAt.getTime()) / step)\n const skipped = weeklyScheduleTemplate && freq === 'WEEKLY' && count === undefined ? rangeOffset : Math.max(0, rangeOffset)\n let cursor = new Date(startAt.getTime() + skipped * step)\n let remaining = (count ?? Number.POSITIVE_INFINITY) - skipped\n while (cursor < range.end && remaining > 0) {\n addWindow(new Date(cursor))\n cursor = new Date(cursor.getTime() + step)\n remaining -= 1\n }\n return windows\n}\n\nfunction expandRules(rules: AvailabilityRuleLike[], range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {\n const expanded = rules.flatMap((rule) => {\n const parsed = parseRrule(rule.rrule)\n if (!parsed) return []\n return expandRule(rule, parsed, range, weeklyScheduleTemplate)\n })\n return expanded.sort((a, b) => a.start.getTime() - b.start.getTime())\n}\n\nfunction subtractWindow(window: AvailabilityWindow, blockers: AvailabilityWindow[]): AvailabilityWindow[] {\n let segments = [window]\n blockers.forEach((blocker) => {\n const next: AvailabilityWindow[] = []\n segments.forEach((segment) => {\n if (blocker.end <= segment.start || blocker.start >= segment.end) {\n next.push(segment)\n return\n }\n if (blocker.start > segment.start) {\n next.push({ ...segment, end: new Date(blocker.start) })\n }\n if (blocker.end < segment.end) {\n next.push({ ...segment, start: new Date(blocker.end) })\n }\n })\n segments = next\n })\n return segments\n}\n\nexport function getMergedAvailabilityWindows(params: {\n rules: AvailabilityRuleLike[]\n range: AvailabilityRange\n respectTimezone?: boolean\n weeklyScheduleTemplate?: boolean\n}): AvailabilityWindow[] {\n const rules = params.respectTimezone ? params.rules : params.rules.map((rule) => ({ ...rule, timezone: undefined }))\n const parsedRules = rules\n .map((rule) => {\n const parsed = parseRrule(rule.rrule)\n if (!parsed) return null\n return { rule, parsed }\n })\n .filter((entry): entry is { rule: AvailabilityRuleLike; parsed: ParsedRule } => entry !== null)\n\n const overrideDays = new Map<string, { kind: AvailabilityKind; dayKey: string; timezone?: string }>()\n parsedRules\n .filter(({ parsed }) => parsed.repeat === 'once')\n .forEach(({ rule, parsed }) => {\n const dayKey = dayKeyForRule(parsed.startAt, rule.timezone)\n const overrideKey = `${rule.timezone ?? 'UTC'}|${dayKey}`\n const kind = rule.kind === 'unavailability' ? 'unavailability' : 'availability'\n if (kind === 'unavailability') {\n overrideDays.set(overrideKey, { kind, dayKey, timezone: rule.timezone })\n } else if (!overrideDays.has(overrideKey)) {\n overrideDays.set(overrideKey, { kind, dayKey, timezone: rule.timezone })\n }\n })\n\n const availabilityRules = parsedRules\n .filter(({ parsed, rule }) => parsed.repeat !== 'once' && rule.kind !== 'unavailability')\n .map(({ rule }) => rule)\n const unavailabilityRules = parsedRules\n .filter(({ parsed, rule }) => parsed.repeat !== 'once' && rule.kind === 'unavailability')\n .map(({ rule }) => rule)\n\n const availabilityWindows = availabilityRules.flatMap((rule) =>\n expandRules([rule], params.range, params.weeklyScheduleTemplate === true).filter((window) =>\n !overrideDays.has(`${rule.timezone ?? 'UTC'}|${dayKeyForRule(window.start, rule.timezone)}`)))\n const unavailabilityWindows = expandRules(unavailabilityRules, params.range, params.weeklyScheduleTemplate === true)\n\n const merged = unavailabilityWindows.length === 0\n ? availabilityWindows\n : availabilityWindows.flatMap((window) => subtractWindow(window, unavailabilityWindows))\n\n const overrideWindows: AvailabilityWindow[] = []\n overrideDays.forEach(({ kind, dayKey, timezone }) => {\n if (kind !== 'availability') return\n const start = timezone ? zonedStartOfDay(dayKey, timezone) : new Date(`${dayKey}T00:00:00Z`)\n if (!start) return\n if (Number.isNaN(start.getTime())) return\n const nextDay = new Date(new Date(`${dayKey}T00:00:00Z`).getTime() + DAY_MS).toISOString().slice(0, 10)\n const end = timezone ? zonedStartOfDay(nextDay, timezone) : new Date(start.getTime() + DAY_MS)\n if (!end) return\n if (end <= params.range.start || start >= params.range.end) return\n overrideWindows.push({ start, end })\n })\n\n return [...merged, ...overrideWindows].sort((a, b) => a.start.getTime() - b.start.getTime())\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { resolveTimeZone, zonedWallDateToInstant } from './availabilityTimezone'\n\ntype AvailabilityKind = 'availability' | 'unavailability'\n\nexport type AvailabilityRuleLike = {\n id?: string\n rrule: string\n timezone?: string\n exdates?: string[]\n kind?: AvailabilityKind\n note?: string | null\n}\n\nexport type AvailabilityRange = {\n start: Date\n end: Date\n}\n\nexport type AvailabilityWindow = {\n start: Date\n end: Date\n ruleId?: string\n}\n\ntype ParsedRule = {\n startAt: Date\n durationMinutes: number\n freq: 'DAILY' | 'WEEKLY'\n repeat: 'once' | 'daily' | 'weekly'\n count?: number\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000\nconst timezoneFormatters = new Map<string, Intl.DateTimeFormat>()\n\nfunction zonedParts(instant: Date, timezone: string): { year: number; month: number; day: number; hour: number; minute: number; second: number } {\n let formatter = timezoneFormatters.get(timezone)\n if (!formatter) {\n formatter = new Intl.DateTimeFormat('en-US', {\n timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',\n hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',\n })\n if (timezoneFormatters.size >= 64) timezoneFormatters.delete(timezoneFormatters.keys().next().value!)\n timezoneFormatters.set(timezone, formatter)\n }\n const parts = formatter.formatToParts(instant)\n const number = (kind: string) => Number(parts.find((part) => part.type === kind)?.value)\n return { year: number('year'), month: number('month'), day: number('day'), hour: number('hour'), minute: number('minute'), second: number('second') }\n}\n\nfunction zonedStartOfDay(dayKey: string, timezone: string): Date | null {\n return zonedWallDateToInstant(new Date(`${dayKey}T00:00:00Z`), timezone)\n}\n\nfunction dayKeyForRule(instant: Date, timezone?: string): string {\n if (!timezone || timezone === 'UTC' || timezone === 'Etc/UTC' || timezone === 'Etc/GMT') return toDayKey(instant)\n const parts = zonedParts(instant, timezone)\n return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`\n}\n\nfunction parseRrule(rule: string): ParsedRule | null {\n const dtStartMatch = rule.match(/DTSTART[:=](\\d{8}T\\d{6}Z?)/)\n const durationMatch = rule.match(/DURATION:PT(?:(\\d+)H)?(?:(\\d+)M)?/)\n const freqMatch = rule.match(/FREQ=([A-Z]+)/)\n if (!dtStartMatch?.[1] || !durationMatch || !freqMatch?.[1]) return null\n const raw = dtStartMatch[1].replace(/Z$/, '')\n const parts = raw.match(/(\\d{4})(\\d{2})(\\d{2})T(\\d{2})(\\d{2})(\\d{2})/)\n if (!parts) return null\n const [, year, month, day, hour, minute, second] = parts\n const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`\n const startAt = new Date(iso)\n if (Number.isNaN(startAt.getTime())) return null\n\n const hours = durationMatch[1] ? Number(durationMatch[1]) : 0\n const minutes = durationMatch[2] ? Number(durationMatch[2]) : 0\n const durationMinutes = Math.max(1, hours * 60 + minutes)\n const freq = freqMatch[1]\n if (freq !== 'DAILY' && freq !== 'WEEKLY') return null\n\n const countMatch = rule.match(/COUNT=(\\d+)/)\n const count = countMatch?.[1] ? Number(countMatch[1]) : undefined\n const repeat = freq === 'WEEKLY'\n ? 'weekly'\n : freq === 'DAILY' && count === 1\n ? 'once'\n : 'daily'\n return { startAt, durationMinutes, freq, count, repeat }\n}\n\nfunction buildExdateSets(exdates?: string[]) {\n const dateOnly = new Set<string>()\n const dateTime = new Set<number>()\n ;(exdates ?? []).forEach((value) => {\n if (typeof value !== 'string') return\n const trimmed = value.trim()\n if (!trimmed) return\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) {\n dateOnly.add(trimmed)\n return\n }\n const parsed = new Date(trimmed)\n if (!Number.isNaN(parsed.getTime())) {\n dateTime.add(parsed.getTime())\n }\n })\n return { dateOnly, dateTime }\n}\n\nfunction shouldExcludeOccurrence(startAt: Date, exdates?: string[], timezone?: string): boolean {\n const { dateOnly, dateTime } = buildExdateSets(exdates)\n if (dateTime.has(startAt.getTime())) return true\n const dayKey = dayKeyForRule(startAt, timezone)\n return dateOnly.has(dayKey)\n}\n\nfunction toDayKey(value: Date): string {\n return value.toISOString().slice(0, 10)\n}\n\nfunction startOfDay(value: Date): Date {\n return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))\n}\n\nfunction expandZonedRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {\n const timezone = rule.timezone!\n const anchor = zonedParts(parsed.startAt, timezone)\n const anchorWall = Date.UTC(anchor.year, anchor.month - 1, anchor.day, anchor.hour, anchor.minute, anchor.second)\n const step = parsed.freq === 'WEEKLY' ? 7 * DAY_MS : DAY_MS\n const duration = parsed.durationMinutes * 60000\n const windows: AvailabilityWindow[] = []\n const rangeAnchor = zonedParts(range.start, timezone)\n const rangeStartWall = Date.UTC(rangeAnchor.year, rangeAnchor.month - 1, rangeAnchor.day, rangeAnchor.hour, rangeAnchor.minute, rangeAnchor.second)\n const rangeOffset = Math.floor((rangeStartWall - duration - DAY_MS - anchorWall) / step)\n const skipped = weeklyScheduleTemplate && parsed.freq === 'WEEKLY' && parsed.count === undefined ? rangeOffset : Math.max(0, rangeOffset)\n let wall = new Date(anchorWall + skipped * step)\n let remaining = (parsed.count ?? Number.POSITIVE_INFINITY) - skipped\n while (wall.getTime() <= range.end.getTime() + DAY_MS && remaining > 0) {\n const start = zonedWallDateToInstant(wall, timezone)\n if (!shouldExcludeOccurrence(start, rule.exdates, timezone)) {\n const end = zonedWallDateToInstant(new Date(wall.getTime() + duration), timezone)\n if (end > start && end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id })\n }\n wall = new Date(wall.getTime() + step)\n remaining -= 1\n }\n return windows\n}\n\nfunction expandRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {\n const { startAt, durationMinutes, freq, count, repeat } = parsed\n if (repeat === 'once') {\n if (shouldExcludeOccurrence(startAt, rule.exdates, rule.timezone)) return []\n const dayKey = dayKeyForRule(startAt, rule.timezone)\n const start = rule.timezone ? zonedStartOfDay(dayKey, rule.timezone) : startOfDay(startAt)\n if (!start) return []\n const nextDay = new Date(new Date(`${dayKey}T00:00:00Z`).getTime() + DAY_MS).toISOString().slice(0, 10)\n const end = rule.timezone ? zonedStartOfDay(nextDay, rule.timezone) : new Date(start.getTime() + DAY_MS)\n if (!end) return []\n if (end <= range.start || start >= range.end) return []\n return [{ start, end, ruleId: rule.id }]\n }\n if (rule.timezone && !['UTC', 'Etc/UTC', 'Etc/GMT'].includes(rule.timezone)) {\n return expandZonedRule(rule, parsed, range, weeklyScheduleTemplate)\n }\n const durationMs = durationMinutes * 60000\n const windows: AvailabilityWindow[] = []\n const addWindow = (start: Date) => {\n if (shouldExcludeOccurrence(start, rule.exdates)) return\n const end = new Date(start.getTime() + durationMs)\n if (end <= range.start || start >= range.end) return\n windows.push({ start, end, ruleId: rule.id })\n }\n\n const step = freq === 'WEEKLY' ? 7 * DAY_MS : DAY_MS\n const rangeOffset = Math.floor((range.start.getTime() - durationMs - startAt.getTime()) / step)\n const skipped = weeklyScheduleTemplate && freq === 'WEEKLY' && count === undefined ? rangeOffset : Math.max(0, rangeOffset)\n let cursor = new Date(startAt.getTime() + skipped * step)\n let remaining = (count ?? Number.POSITIVE_INFINITY) - skipped\n while (cursor < range.end && remaining > 0) {\n addWindow(new Date(cursor))\n cursor = new Date(cursor.getTime() + step)\n remaining -= 1\n }\n return windows\n}\n\nfunction expandRules(rules: AvailabilityRuleLike[], range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {\n const expanded = rules.flatMap((rule) => {\n const parsed = parseRrule(rule.rrule)\n if (!parsed) return []\n return expandRule(rule, parsed, range, weeklyScheduleTemplate)\n })\n return expanded.sort((a, b) => a.start.getTime() - b.start.getTime())\n}\n\nfunction subtractWindow(window: AvailabilityWindow, blockers: AvailabilityWindow[]): AvailabilityWindow[] {\n let segments = [window]\n blockers.forEach((blocker) => {\n const next: AvailabilityWindow[] = []\n segments.forEach((segment) => {\n if (blocker.end <= segment.start || blocker.start >= segment.end) {\n next.push(segment)\n return\n }\n if (blocker.start > segment.start) {\n next.push({ ...segment, end: new Date(blocker.start) })\n }\n if (blocker.end < segment.end) {\n next.push({ ...segment, start: new Date(blocker.end) })\n }\n })\n segments = next\n })\n return segments\n}\n\nexport function getMergedAvailabilityWindows(params: {\n rules: AvailabilityRuleLike[]\n range: AvailabilityRange\n respectTimezone?: boolean\n weeklyScheduleTemplate?: boolean\n}): AvailabilityWindow[] {\n const rules: AvailabilityRuleLike[] = params.respectTimezone\n ? params.rules.map((rule) => ({\n ...rule,\n timezone: rule.timezone ? resolveTimeZone(rule.timezone) : rule.timezone,\n }))\n : params.rules.map((rule) => ({ ...rule, timezone: undefined }))\n const parsedRules = rules\n .map((rule) => {\n const parsed = parseRrule(rule.rrule)\n if (!parsed) return null\n return { rule, parsed }\n })\n .filter((entry): entry is { rule: AvailabilityRuleLike; parsed: ParsedRule } => entry !== null)\n\n const overrideDays = new Map<string, { kind: AvailabilityKind; dayKey: string; timezone?: string }>()\n parsedRules\n .filter(({ parsed }) => parsed.repeat === 'once')\n .forEach(({ rule, parsed }) => {\n const dayKey = dayKeyForRule(parsed.startAt, rule.timezone)\n const overrideKey = `${rule.timezone ?? 'UTC'}|${dayKey}`\n const kind = rule.kind === 'unavailability' ? 'unavailability' : 'availability'\n if (kind === 'unavailability') {\n overrideDays.set(overrideKey, { kind, dayKey, timezone: rule.timezone })\n } else if (!overrideDays.has(overrideKey)) {\n overrideDays.set(overrideKey, { kind, dayKey, timezone: rule.timezone })\n }\n })\n\n const availabilityRules = parsedRules\n .filter(({ parsed, rule }) => parsed.repeat !== 'once' && rule.kind !== 'unavailability')\n .map(({ rule }) => rule)\n const unavailabilityRules = parsedRules\n .filter(({ parsed, rule }) => parsed.repeat !== 'once' && rule.kind === 'unavailability')\n .map(({ rule }) => rule)\n\n const availabilityWindows = availabilityRules.flatMap((rule) =>\n expandRules([rule], params.range, params.weeklyScheduleTemplate === true).filter((window) =>\n !overrideDays.has(`${rule.timezone ?? 'UTC'}|${dayKeyForRule(window.start, rule.timezone)}`)))\n const unavailabilityWindows = expandRules(unavailabilityRules, params.range, params.weeklyScheduleTemplate === true)\n\n const merged = unavailabilityWindows.length === 0\n ? availabilityWindows\n : availabilityWindows.flatMap((window) => subtractWindow(window, unavailabilityWindows))\n\n const overrideWindows: AvailabilityWindow[] = []\n overrideDays.forEach(({ kind, dayKey, timezone }) => {\n if (kind !== 'availability') return\n const start = timezone ? zonedStartOfDay(dayKey, timezone) : new Date(`${dayKey}T00:00:00Z`)\n if (!start) return\n if (Number.isNaN(start.getTime())) return\n const nextDay = new Date(new Date(`${dayKey}T00:00:00Z`).getTime() + DAY_MS).toISOString().slice(0, 10)\n const end = timezone ? zonedStartOfDay(nextDay, timezone) : new Date(start.getTime() + DAY_MS)\n if (!end) return\n if (end <= params.range.start || start >= params.range.end) return\n overrideWindows.push({ start, end })\n })\n\n return [...merged, ...overrideWindows].sort((a, b) => a.start.getTime() - b.start.getTime())\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,161 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ const logger = createLogger("planner").child({ component: "availability" });
3
+ const FALLBACK_TIME_ZONE = "UTC";
4
+ const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
5
+ function resolveTimeZone(value) {
6
+ if (!isResolvableTimeZone(value)) return FALLBACK_TIME_ZONE;
7
+ return value.trim();
8
+ }
9
+ function isResolvableTimeZone(value) {
10
+ if (typeof value !== "string" || !value.trim().length) return false;
11
+ try {
12
+ new Intl.DateTimeFormat("en-US", { timeZone: value.trim() });
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+ function warnOnUnresolvableTimeZone(value, commandId) {
19
+ if (isResolvableTimeZone(value)) return;
20
+ logger.warn("[deprecated] Unresolvable availability timezone anchored to UTC; this will be rejected with 400 in a future minor release", {
21
+ commandId,
22
+ timezone: value,
23
+ fallback: FALLBACK_TIME_ZONE
24
+ });
25
+ }
26
+ function parseTimeOfDay(value) {
27
+ const [hours, minutes] = String(value).split(":").map((part) => Number(part));
28
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
29
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
30
+ return { hours, minutes };
31
+ }
32
+ const MINUTE_MS = 60 * 1e3;
33
+ const DAY_MS = 24 * 60 * MINUTE_MS;
34
+ const formatterCache = /* @__PURE__ */ new Map();
35
+ function zonedFormatter(timeZone) {
36
+ let formatter = formatterCache.get(timeZone);
37
+ if (!formatter) {
38
+ formatter = new Intl.DateTimeFormat("en-US", {
39
+ timeZone,
40
+ hourCycle: "h23",
41
+ year: "numeric",
42
+ month: "2-digit",
43
+ day: "2-digit",
44
+ hour: "2-digit",
45
+ minute: "2-digit",
46
+ second: "2-digit"
47
+ });
48
+ formatterCache.set(timeZone, formatter);
49
+ }
50
+ return formatter;
51
+ }
52
+ function zonedWallClock(epochMs, timeZone) {
53
+ const values = {};
54
+ for (const part of zonedFormatter(timeZone).formatToParts(new Date(epochMs))) {
55
+ if (part.type !== "literal") values[part.type] = Number(part.value);
56
+ }
57
+ return {
58
+ year: values.year,
59
+ month: values.month,
60
+ day: values.day,
61
+ hours: values.hour,
62
+ minutes: values.minute,
63
+ seconds: values.second
64
+ };
65
+ }
66
+ function wallClockAsUtc(clock) {
67
+ return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hours, clock.minutes, clock.seconds);
68
+ }
69
+ function offsetAt(epochMs, timeZone) {
70
+ const flooredToMinute = Math.floor(epochMs / MINUTE_MS) * MINUTE_MS;
71
+ return wallClockAsUtc(zonedWallClock(flooredToMinute, timeZone)) - flooredToMinute;
72
+ }
73
+ function parseDateKey(dateKey) {
74
+ if (!DATE_KEY_PATTERN.test(String(dateKey))) return null;
75
+ const [year, month, day] = dateKey.split("-").map((part) => Number(part));
76
+ const probe = new Date(Date.UTC(year, month - 1, day));
77
+ if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;
78
+ return { year, month, day };
79
+ }
80
+ function formatUtcDateKey(epochMs) {
81
+ return new Date(epochMs).toISOString().slice(0, 10);
82
+ }
83
+ function zonedWallTimeToInstant(dateKey, time, timeZone) {
84
+ const date = parseDateKey(dateKey);
85
+ const parsed = parseTimeOfDay(time);
86
+ if (!date || !parsed) return null;
87
+ const wall = new Date(Date.UTC(date.year, date.month - 1, date.day, parsed.hours, parsed.minutes));
88
+ return zonedWallDateToInstant(wall, timeZone);
89
+ }
90
+ function zonedWallDateToInstant(wall, timeZone) {
91
+ const zone = resolveTimeZone(timeZone);
92
+ const wallAsUtc = wall.getTime();
93
+ const offsetBefore = offsetAt(wallAsUtc - DAY_MS, zone);
94
+ const offsetAfter = offsetAt(wallAsUtc + DAY_MS, zone);
95
+ if (offsetBefore === offsetAfter) return new Date(wallAsUtc - offsetBefore);
96
+ const candidates = [wallAsUtc - offsetBefore, wallAsUtc - offsetAfter].sort((left, right) => left - right);
97
+ const matching = candidates.find((candidate) => candidate + offsetAt(candidate, zone) === wallAsUtc);
98
+ return new Date(matching ?? wallAsUtc - offsetBefore);
99
+ }
100
+ function zonedDayBounds(dateKey, timeZone) {
101
+ const date = parseDateKey(dateKey);
102
+ if (!date) return null;
103
+ const nextDateKey = formatUtcDateKey(Date.UTC(date.year, date.month - 1, date.day) + DAY_MS);
104
+ const start = zonedWallTimeToInstant(dateKey, "00:00", timeZone);
105
+ const end = zonedWallTimeToInstant(nextDateKey, "00:00", timeZone);
106
+ if (!start || !end || end <= start) return null;
107
+ return { start, end };
108
+ }
109
+ function zonedDateKey(instant, timeZone) {
110
+ const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone));
111
+ return formatUtcDateKey(Date.UTC(clock.year, clock.month - 1, clock.day));
112
+ }
113
+ function zonedWeekday(instant, timeZone) {
114
+ const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone));
115
+ return new Date(Date.UTC(clock.year, clock.month - 1, clock.day)).getUTCDay();
116
+ }
117
+ function nextWeekdayDateKey(weekday, timeZone, from = /* @__PURE__ */ new Date()) {
118
+ const clock = zonedWallClock(from.getTime(), resolveTimeZone(timeZone));
119
+ const localMidnight = Date.UTC(clock.year, clock.month - 1, clock.day);
120
+ const diff = (weekday - new Date(localMidnight).getUTCDay() + 7) % 7;
121
+ return formatUtcDateKey(localMidnight + diff * DAY_MS);
122
+ }
123
+ function zonedWeeklyAnchor(weekday, startTime, endTime, timeZone, from) {
124
+ const startClock = parseTimeOfDay(startTime);
125
+ const endClock = parseTimeOfDay(endTime);
126
+ if (!startClock || !endClock) return null;
127
+ const zone = resolveTimeZone(timeZone);
128
+ const startMinutes = startClock.hours * 60 + startClock.minutes;
129
+ const endMinutes = endClock.hours * 60 + endClock.minutes;
130
+ const localLengthMs = (endMinutes - startMinutes) * MINUTE_MS;
131
+ const upcoming = parseDateKey(nextWeekdayDateKey(weekday, timeZone, from));
132
+ if (!upcoming) return null;
133
+ const upcomingMidnight = Date.UTC(upcoming.year, upcoming.month - 1, upcoming.day);
134
+ let fallback = null;
135
+ for (const weekOffset of [0, -1]) {
136
+ const midnight = upcomingMidnight + weekOffset * 7 * DAY_MS;
137
+ const dateKey = formatUtcDateKey(midnight);
138
+ const start = zonedWallTimeToInstant(dateKey, startTime, timeZone);
139
+ const end = zonedWallTimeToInstant(dateKey, endTime, timeZone);
140
+ if (!start || !end) return null;
141
+ const startWall = wallClockAsUtc(zonedWallClock(start.getTime(), zone));
142
+ const endWall = wallClockAsUtc(zonedWallClock(end.getTime(), zone));
143
+ if (startWall !== midnight + startMinutes * MINUTE_MS || endWall !== midnight + endMinutes * MINUTE_MS) continue;
144
+ if (end.getTime() - start.getTime() === localLengthMs) return { start, end };
145
+ fallback ??= { start, end };
146
+ }
147
+ return fallback;
148
+ }
149
+ export {
150
+ isResolvableTimeZone,
151
+ nextWeekdayDateKey,
152
+ resolveTimeZone,
153
+ warnOnUnresolvableTimeZone,
154
+ zonedDateKey,
155
+ zonedDayBounds,
156
+ zonedWallDateToInstant,
157
+ zonedWallTimeToInstant,
158
+ zonedWeekday,
159
+ zonedWeeklyAnchor
160
+ };
161
+ //# sourceMappingURL=availabilityTimezone.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/planner/lib/availabilityTimezone.ts"],
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('planner').child({ component: 'availability' })\n\nconst FALLBACK_TIME_ZONE = 'UTC'\nconst DATE_KEY_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/\n\n/**\n * Availability rows store `timezone` as free-form `text`, so a malformed value\n * must not turn a read or a write into a 500. Unknown zones degrade to UTC,\n * which is already how the expander interprets every stored instant.\n */\nexport function resolveTimeZone(value: string | null | undefined): string {\n if (!isResolvableTimeZone(value)) return FALLBACK_TIME_ZONE\n return value.trim()\n}\n\nexport function isResolvableTimeZone(value: string | null | undefined): value is string {\n if (typeof value !== 'string' || !value.trim().length) return false\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value.trim() })\n return true\n } catch {\n return false\n }\n}\n\n/**\n * The replace endpoints anchor `DTSTART` in the declared zone, so an\n * unresolvable value is persisted as a UTC-anchored instant under a label\n * nothing can resolve. Rejecting it with a 400 would narrow the replace\n * schemas (a `data/validators.ts` contract surface), so for now the write is\n * accepted and flagged; the 400 is announced in UPGRADE_NOTES.md.\n */\nexport function warnOnUnresolvableTimeZone(value: string, commandId: string): void {\n if (isResolvableTimeZone(value)) return\n logger.warn('[deprecated] Unresolvable availability timezone anchored to UTC; this will be rejected with 400 in a future minor release', {\n commandId,\n timezone: value,\n fallback: FALLBACK_TIME_ZONE,\n })\n}\n\nfunction parseTimeOfDay(value: string): { hours: number; minutes: number } | null {\n const [hours, minutes] = String(value).split(':').map((part) => Number(part))\n if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null\n if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null\n return { hours, minutes }\n}\n\ntype WallClock = { year: number; month: number; day: number; hours: number; minutes: number; seconds: number }\n\nconst MINUTE_MS = 60 * 1000\nconst DAY_MS = 24 * 60 * MINUTE_MS\nconst formatterCache = new Map<string, Intl.DateTimeFormat>()\n\nfunction zonedFormatter(timeZone: string): Intl.DateTimeFormat {\n let formatter = formatterCache.get(timeZone)\n if (!formatter) {\n formatter = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hourCycle: 'h23',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n })\n formatterCache.set(timeZone, formatter)\n }\n return formatter\n}\n\n/**\n * Wall clock of an instant in `timeZone`, read through `Intl` only. `date-fns-tz`\n * seeds its offset search with host-local getters, so around DST transitions\n * the same input resolved differently depending on the serving machine's `TZ`.\n */\nfunction zonedWallClock(epochMs: number, timeZone: string): WallClock {\n const values: Record<string, number> = {}\n for (const part of zonedFormatter(timeZone).formatToParts(new Date(epochMs))) {\n if (part.type !== 'literal') values[part.type] = Number(part.value)\n }\n return {\n year: values.year,\n month: values.month,\n day: values.day,\n hours: values.hour,\n minutes: values.minute,\n seconds: values.second,\n }\n}\n\nfunction wallClockAsUtc(clock: WallClock): number {\n return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hours, clock.minutes, clock.seconds)\n}\n\nfunction offsetAt(epochMs: number, timeZone: string): number {\n const flooredToMinute = Math.floor(epochMs / MINUTE_MS) * MINUTE_MS\n return wallClockAsUtc(zonedWallClock(flooredToMinute, timeZone)) - flooredToMinute\n}\n\nfunction parseDateKey(dateKey: string): { year: number; month: number; day: number } | null {\n if (!DATE_KEY_PATTERN.test(String(dateKey))) return null\n const [year, month, day] = dateKey.split('-').map((part) => Number(part))\n const probe = new Date(Date.UTC(year, month - 1, day))\n if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null\n return { year, month, day }\n}\n\nfunction formatUtcDateKey(epochMs: number): string {\n return new Date(epochMs).toISOString().slice(0, 10)\n}\n\n/**\n * `2026-06-15` + `09:00` + `Europe/Warsaw` \u2192 the absolute instant that wall\n * time denotes. Replaces `new Date(y, m, d, h, min)`, whose result depended on\n * the host process's `TZ` rather than on the zone the caller declared (#5862).\n *\n * DST transitions follow the `compatible` disambiguation of RFC 5545 \u00A73.3.5 and\n * Temporal: a repeated wall time resolves to its EARLIER occurrence, and a\n * skipped one moves forward by the length of the gap (`Africa/Cairo` has no\n * 2026-04-24 00:00, so it becomes 01:00 \u2014 the first instant of that date).\n */\nexport function zonedWallTimeToInstant(dateKey: string, time: string, timeZone: string): Date | null {\n const date = parseDateKey(dateKey)\n const parsed = parseTimeOfDay(time)\n if (!date || !parsed) return null\n const wall = new Date(Date.UTC(date.year, date.month - 1, date.day, parsed.hours, parsed.minutes))\n return zonedWallDateToInstant(wall, timeZone)\n}\n\n/** Resolve a UTC-encoded wall clock with compatible DST disambiguation. */\nexport function zonedWallDateToInstant(wall: Date, timeZone: string): Date {\n const zone = resolveTimeZone(timeZone)\n const wallAsUtc = wall.getTime()\n const offsetBefore = offsetAt(wallAsUtc - DAY_MS, zone)\n const offsetAfter = offsetAt(wallAsUtc + DAY_MS, zone)\n if (offsetBefore === offsetAfter) return new Date(wallAsUtc - offsetBefore)\n const candidates = [wallAsUtc - offsetBefore, wallAsUtc - offsetAfter].sort((left, right) => left - right)\n const matching = candidates.find((candidate) => candidate + offsetAt(candidate, zone) === wallAsUtc)\n return new Date(matching ?? wallAsUtc - offsetBefore)\n}\n\n/**\n * The span a full-day block covers: from the first instant of `dateKey` to the\n * first instant of the following date, both in `timeZone`. That is 23 or 25\n * hours on DST days, so the block neither bleeds into nor falls short of its\n * neighbours.\n */\nexport function zonedDayBounds(dateKey: string, timeZone: string): { start: Date; end: Date } | null {\n const date = parseDateKey(dateKey)\n if (!date) return null\n const nextDateKey = formatUtcDateKey(Date.UTC(date.year, date.month - 1, date.day) + DAY_MS)\n const start = zonedWallTimeToInstant(dateKey, '00:00', timeZone)\n const end = zonedWallTimeToInstant(nextDateKey, '00:00', timeZone)\n if (!start || !end || end <= start) return null\n return { start, end }\n}\n\n/**\n * The calendar date an instant falls on, as seen from `timeZone`.\n *\n * Which day an existing rule belongs to has to be read in that rule's own zone,\n * the same zone its `DTSTART` was anchored in. Derived from the host clock\n * instead, replace-by-date selected a different set of rows \u2014 and the 403 gate\n * in `api/availability-date-specific.ts` admitted or refused the same request \u2014\n * depending on which machine served it.\n */\nexport function zonedDateKey(instant: Date, timeZone: string): string {\n const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))\n return formatUtcDateKey(Date.UTC(clock.year, clock.month - 1, clock.day))\n}\n\n/** `Date.getDay()` index (0 = Sunday) of an instant, as seen from `timeZone`. */\nexport function zonedWeekday(instant: Date, timeZone: string): number {\n const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))\n return new Date(Date.UTC(clock.year, clock.month - 1, clock.day)).getUTCDay()\n}\n\n/**\n * The next occurrence of `weekday` (0 = Sunday) at or after `from`, as a\n * calendar date key in `timeZone`. The zone matters: at 12:00 UTC on a Monday\n * it is already Tuesday in `Pacific/Auckland`, which shifted the whole series\n * by a week before this was zone-aware.\n */\nexport function nextWeekdayDateKey(weekday: number, timeZone: string, from: Date = new Date()): string {\n const clock = zonedWallClock(from.getTime(), resolveTimeZone(timeZone))\n const localMidnight = Date.UTC(clock.year, clock.month - 1, clock.day)\n const diff = (weekday - new Date(localMidnight).getUTCDay() + 7) % 7\n return formatUtcDateKey(localMidnight + diff * DAY_MS)\n}\n\n/**\n * The start and end instants of the occurrence a weekly window is anchored on.\n *\n * The anchor's exact length becomes the rule's `DURATION`, which every reader\n * applies to every week. An anchor that spans a DST transition (Europe/Warsaw\n * Sunday 00:00\u201308:00 on 2026-10-25 lasts 9 hours) or starts in a gap would\n * therefore misstate the window for all ordinary weeks, so the upcoming\n * occurrence is used only when both local endpoints and its exact length match\n * the request; otherwise the previous week's occurrence anchors the series. Taking\n * the earlier week keeps the upcoming occurrence inside the series for readers\n * that expand forward from `DTSTART`.\n *\n * `from` is threaded in rather than defaulted per call: resolving a window's\n * start and end against two separate `new Date()` reads can straddle midnight\n * in the declared zone, which lands the anchors seven days apart.\n */\nexport function zonedWeeklyAnchor(\n weekday: number,\n startTime: string,\n endTime: string,\n timeZone: string,\n from: Date,\n): { start: Date; end: Date } | null {\n const startClock = parseTimeOfDay(startTime)\n const endClock = parseTimeOfDay(endTime)\n if (!startClock || !endClock) return null\n const zone = resolveTimeZone(timeZone)\n const startMinutes = startClock.hours * 60 + startClock.minutes\n const endMinutes = endClock.hours * 60 + endClock.minutes\n const localLengthMs = (endMinutes - startMinutes) * MINUTE_MS\n const upcoming = parseDateKey(nextWeekdayDateKey(weekday, timeZone, from))\n if (!upcoming) return null\n const upcomingMidnight = Date.UTC(upcoming.year, upcoming.month - 1, upcoming.day)\n let fallback: { start: Date; end: Date } | null = null\n for (const weekOffset of [0, -1]) {\n const midnight = upcomingMidnight + weekOffset * 7 * DAY_MS\n const dateKey = formatUtcDateKey(midnight)\n const start = zonedWallTimeToInstant(dateKey, startTime, timeZone)\n const end = zonedWallTimeToInstant(dateKey, endTime, timeZone)\n if (!start || !end) return null\n const startWall = wallClockAsUtc(zonedWallClock(start.getTime(), zone))\n const endWall = wallClockAsUtc(zonedWallClock(end.getTime(), zone))\n if (startWall !== midnight + startMinutes * MINUTE_MS || endWall !== midnight + endMinutes * MINUTE_MS) continue\n if (end.getTime() - start.getTime() === localLengthMs) return { start, end }\n fallback ??= { start, end }\n }\n return fallback\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7724.1.95e4e7d38e",
3
+ "version": "0.10.1-develop.7727.1.0b89c4f800",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,6 +9,7 @@
9
9
  "watch": "node watch.mjs",
10
10
  "test": "jest --config jest.config.cjs",
11
11
  "test:pg-integration": "cross-env OM_PG_INTEGRATION=1 jest --config jest.config.cjs --runInBand 'src/modules/(integrations|staff)/.+\\.integration\\.test\\.ts$'",
12
+ "test:tz": "cross-env TZ=Pacific/Auckland jest --config jest.config.cjs",
12
13
  "typecheck": "tsc --noEmit"
13
14
  },
14
15
  "imports": {
@@ -265,16 +266,16 @@
265
266
  "zod": "^4.4.3"
266
267
  },
267
268
  "peerDependencies": {
268
- "@open-mercato/ai-assistant": "0.10.1-develop.7724.1.95e4e7d38e",
269
- "@open-mercato/shared": "0.10.1-develop.7724.1.95e4e7d38e",
270
- "@open-mercato/ui": "0.10.1-develop.7724.1.95e4e7d38e",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7727.1.0b89c4f800",
270
+ "@open-mercato/shared": "0.10.1-develop.7727.1.0b89c4f800",
271
+ "@open-mercato/ui": "0.10.1-develop.7727.1.0b89c4f800",
271
272
  "react": "^19.0.0",
272
273
  "react-dom": "^19.0.0"
273
274
  },
274
275
  "devDependencies": {
275
- "@open-mercato/ai-assistant": "0.10.1-develop.7724.1.95e4e7d38e",
276
- "@open-mercato/shared": "0.10.1-develop.7724.1.95e4e7d38e",
277
- "@open-mercato/ui": "0.10.1-develop.7724.1.95e4e7d38e",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7727.1.0b89c4f800",
277
+ "@open-mercato/shared": "0.10.1-develop.7727.1.0b89c4f800",
278
+ "@open-mercato/ui": "0.10.1-develop.7727.1.0b89c4f800",
278
279
  "@testing-library/dom": "^10.4.1",
279
280
  "@testing-library/jest-dom": "^7.0.1",
280
281
  "@testing-library/react": "^16.3.3",
@@ -6,6 +6,7 @@ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
6
6
  import { Organization, Tenant } from '@open-mercato/core/modules/directory/data/entities'
7
7
  import { organizationCreateSchema, organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'
8
8
  import {
9
+ findOrganizationParentCycle,
9
10
  lockOrganizationHierarchyForTenant,
10
11
  rebuildHierarchyForTenant,
11
12
  } from '@open-mercato/core/modules/directory/lib/hierarchy'
@@ -32,6 +33,7 @@ import {
32
33
  requireId,
33
34
  buildChanges,
34
35
  } from '@open-mercato/shared/lib/commands/helpers'
36
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
35
37
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
36
38
  import { toOptionalString } from '@open-mercato/shared/lib/string/coerce'
37
39
  import { slugify } from '@open-mercato/shared/lib/slugify'
@@ -293,6 +295,32 @@ async function restoreChildParents(em: EntityManager, tenantId: string, snapshot
293
295
  if (toPersist.length) await em.persist(toPersist).flush()
294
296
  }
295
297
 
298
+ const REPLAY_CYCLE_ERROR_KEY = 'directory.organizations.errors.replayCycle'
299
+
300
+ async function assertReplayKeepsHierarchyAcyclic(
301
+ em: EntityManager,
302
+ tenantId: string,
303
+ organizationIds: Iterable<string>,
304
+ ): Promise<void> {
305
+ const filter: FilterQuery<Organization> = {
306
+ tenant: tenantId,
307
+ deletedAt: null,
308
+ } as unknown as FilterQuery<Organization>
309
+ const organizations = await findWithDecryption(em, Organization, filter, undefined, {
310
+ tenantId,
311
+ organizationId: null,
312
+ })
313
+ if (!findOrganizationParentCycle(organizations, organizationIds)) return
314
+ const { translate } = await resolveTranslations()
315
+ throw new CrudHttpError(409, {
316
+ error: translate(
317
+ REPLAY_CYCLE_ERROR_KEY,
318
+ "The organization hierarchy changed since this action, so it can't be reverted without making an organization its own ancestor.",
319
+ ),
320
+ code: REPLAY_CYCLE_ERROR_KEY,
321
+ })
322
+ }
323
+
296
324
  function normalizeChildIds(ids: readonly string[], exclude: string[]): string[] {
297
325
  const excludeSet = new Set(exclude)
298
326
  return Array.from(new Set(ids)).filter((id) => !excludeSet.has(id))
@@ -509,6 +537,11 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
509
537
  organization.parentId = null
510
538
  await em.flush()
511
539
  }
540
+ await assertReplayKeepsHierarchyAcyclic(
541
+ em,
542
+ tenantId,
543
+ childrenBefore.map((entry) => entry.childId).filter((childId) => childId !== after.id),
544
+ )
512
545
  await rebuildHierarchyForTenant(em, tenantId)
513
546
  },
514
547
  ], { transaction: true })
@@ -587,6 +620,10 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
587
620
  }
588
621
 
589
622
  await assignChildren(em, tenantId, after.id, after.childParents.map((entry) => entry.childId))
623
+ await assertReplayKeepsHierarchyAcyclic(em, tenantId, [
624
+ after.id,
625
+ ...after.childParents.map((entry) => entry.childId),
626
+ ])
590
627
  await rebuildHierarchyForTenant(em, tenantId)
591
628
  },
592
629
  ], { transaction: true })
@@ -823,6 +860,10 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
823
860
  }
824
861
  const childSnapshots = before.childParents
825
862
  await restoreChildParents(em, tenantId, childSnapshots)
863
+ await assertReplayKeepsHierarchyAcyclic(em, tenantId, [
864
+ before.id,
865
+ ...(childSnapshots ?? []).map((entry) => entry.childId),
866
+ ])
826
867
  await rebuildHierarchyForTenant(em, tenantId)
827
868
  },
828
869
  ], { transaction: true })
@@ -993,6 +1034,10 @@ const deleteOrganizationCommand: CommandHandler<{ body: any; query: Record<strin
993
1034
  }
994
1035
  }
995
1036
  await restoreChildParents(em, tenantId, before.childParents)
1037
+ await assertReplayKeepsHierarchyAcyclic(em, tenantId, [
1038
+ before.id,
1039
+ ...(before.childParents ?? []).map((entry) => entry.childId),
1040
+ ])
996
1041
  await rebuildHierarchyForTenant(em, tenantId)
997
1042
  },
998
1043
  ], { transaction: true })
@@ -38,6 +38,7 @@
38
38
  "directory.organizations.errors.deleteFailed": "Organisation konnte nicht gelöscht werden",
39
39
  "directory.organizations.errors.invalidQuery": "Ungültige Abfrage",
40
40
  "directory.organizations.errors.orgIdRequired": "Organisationskennung ist erforderlich",
41
+ "directory.organizations.errors.replayCycle": "Die Organisationshierarchie hat sich seit dieser Aktion geändert, daher kann die Aktion nicht rückgängig gemacht werden, ohne dass eine Organisation sich selbst übergeordnet wird.",
41
42
  "directory.organizations.errors.tenantRequired": "Die Mandantenauswahl ist für Superadministratoren erforderlich",
42
43
  "directory.organizations.flash.created": "Organisation erstellt",
43
44
  "directory.organizations.flash.deleted": "Organisation gelöscht",
@@ -38,6 +38,7 @@
38
38
  "directory.organizations.errors.deleteFailed": "Failed to delete organization",
39
39
  "directory.organizations.errors.invalidQuery": "Invalid query",
40
40
  "directory.organizations.errors.orgIdRequired": "Organization identifier is required",
41
+ "directory.organizations.errors.replayCycle": "The organization hierarchy changed since this action, so it can't be reverted without making an organization its own ancestor.",
41
42
  "directory.organizations.errors.tenantRequired": "Tenant selection is required for super administrators",
42
43
  "directory.organizations.flash.created": "Organization created",
43
44
  "directory.organizations.flash.deleted": "Organization deleted",
@@ -38,6 +38,7 @@
38
38
  "directory.organizations.errors.deleteFailed": "No se pudo eliminar la organización",
39
39
  "directory.organizations.errors.invalidQuery": "Consulta no válida",
40
40
  "directory.organizations.errors.orgIdRequired": "Se requiere el identificador de la organización",
41
+ "directory.organizations.errors.replayCycle": "La jerarquía de organizaciones ha cambiado desde esta acción, por lo que no se puede revertir sin que una organización pase a ser su propio ancestro.",
41
42
  "directory.organizations.errors.tenantRequired": "La selección de inquilino es obligatoria para los superadministradores",
42
43
  "directory.organizations.flash.created": "Organización creada",
43
44
  "directory.organizations.flash.deleted": "Organización eliminada",
@@ -38,6 +38,7 @@
38
38
  "directory.organizations.errors.deleteFailed": "조직 삭제에 실패했습니다",
39
39
  "directory.organizations.errors.invalidQuery": "잘못된 쿼리입니다",
40
40
  "directory.organizations.errors.orgIdRequired": "조직 식별자는 필수입니다",
41
+ "directory.organizations.errors.replayCycle": "이 작업 이후 조직 계층 구조가 변경되어, 조직이 자기 자신의 상위 조직이 되지 않고는 되돌릴 수 없습니다.",
41
42
  "directory.organizations.errors.tenantRequired": "슈퍼 관리자는 테넌트 선택이 필요합니다",
42
43
  "directory.organizations.flash.created": "조직이 생성되었습니다",
43
44
  "directory.organizations.flash.deleted": "조직이 삭제되었습니다",
@@ -38,6 +38,7 @@
38
38
  "directory.organizations.errors.deleteFailed": "Nie udało się usunąć organizacji",
39
39
  "directory.organizations.errors.invalidQuery": "Nieprawidłowe zapytanie",
40
40
  "directory.organizations.errors.orgIdRequired": "Identyfikator organizacji jest wymagany",
41
+ "directory.organizations.errors.replayCycle": "Hierarchia organizacji zmieniła się od czasu tej akcji, więc nie można jej cofnąć bez uczynienia organizacji własnym przodkiem.",
41
42
  "directory.organizations.errors.tenantRequired": "Wybór najemcy jest wymagany dla superadministratorów",
42
43
  "directory.organizations.flash.created": "Organizacja utworzona",
43
44
  "directory.organizations.flash.deleted": "Organizacja usunięta",
@@ -55,6 +55,25 @@ function normalizeUuid(value: unknown): string | null {
55
55
  return v
56
56
  }
57
57
 
58
+ export function findOrganizationParentCycle(
59
+ organizations: ReadonlyArray<Pick<Organization, 'id' | 'parentId'>>,
60
+ organizationIds: Iterable<string>,
61
+ ): string | null {
62
+ const parents = new Map<string, string | null>()
63
+ for (const org of organizations) parents.set(String(org.id), normalizeUuid(org.parentId))
64
+ for (const startId of organizationIds) {
65
+ const seen = new Set<string>([startId])
66
+ let currentId = parents.get(startId) ?? null
67
+ while (currentId && parents.has(currentId)) {
68
+ if (currentId === startId) return startId
69
+ if (seen.has(currentId)) break
70
+ seen.add(currentId)
71
+ currentId = parents.get(currentId) ?? null
72
+ }
73
+ }
74
+ return null
75
+ }
76
+
58
77
  export function computeHierarchyForOrganizations(organizations: Organization[], tenantId: string): ComputedHierarchy {
59
78
  const nodes = new Map<string, InternalNode>()
60
79
 
@@ -173,10 +192,30 @@ export function computeHierarchyForOrganizations(organizations: Organization[],
173
192
  walk(id, [])
174
193
  }
175
194
  }
195
+
196
+ function findCycleCutId(startId: string): string | null {
197
+ const positions = new Map<string, number>()
198
+ const path: string[] = []
199
+ let currentId: string | null = startId
200
+ while (currentId && nodes.has(currentId) && !visited.has(currentId) && !positions.has(currentId)) {
201
+ positions.set(currentId, path.length)
202
+ path.push(currentId)
203
+ currentId = nodes.get(currentId)!.parentId
204
+ }
205
+ if (!currentId || !positions.has(currentId)) return null
206
+ return path
207
+ .slice(positions.get(currentId))
208
+ .reduce((smallest, candidate) => (candidate < smallest ? candidate : smallest))
209
+ }
210
+
176
211
  // Handle orphaned nodes or cycles not reached above
177
212
  for (const id of nodes.keys()) {
178
- if (!visited.has(id)) {
179
- walk(id, [])
213
+ while (!visited.has(id)) {
214
+ const cutId = findCycleCutId(id) ?? id
215
+ const cutNode = nodes.get(cutId)!
216
+ if (cutNode.parentId) nodes.get(cutNode.parentId)?.children.delete(cutId)
217
+ cutNode.parentId = null
218
+ walk(cutId, [])
180
219
  }
181
220
  }
182
221
 
@@ -11,6 +11,7 @@ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/er
11
11
  import { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
12
12
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
13
  import { plannerAvailabilityDateSpecificReplaceSchema } from '../data/validators'
14
+ import { zonedDateKey } from '../lib/availabilityTimezone'
14
15
  import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
15
16
  import {
16
17
  runCrudMutationGuardAfterSuccess,
@@ -26,11 +27,9 @@ const logger = createLogger('planner').child({ component: 'availability' })
26
27
  export const metadata = {
27
28
  POST: { requireAuth: true },
28
29
  }
29
-
30
30
  type RequestContext = {
31
31
  ctx: CommandRuntimeContext
32
32
  }
33
-
34
33
  async function resolveRequestContext(req: Request): Promise<RequestContext> {
35
34
  const container = await createRequestContainer()
36
35
  const auth = await getAuthFromRequest(req)
@@ -94,7 +93,10 @@ export async function POST(req: Request) {
94
93
  const blocked = rules.some((rule) => {
95
94
  const window = parseAvailabilityRuleWindow(rule)
96
95
  if (window.repeat !== 'once') return false
97
- return dateSet.has(formatDateKey(window.startAt))
96
+ // Resolved in the rule's own zone so this gate agrees with
97
+ // `planner.availability.date-specific.replace` and does not depend
98
+ // on the host clock.
99
+ return dateSet.has(zonedDateKey(window.startAt, rule.timezone))
98
100
  })
99
101
  if (blocked) {
100
102
  throw new CrudHttpError(403, { error: translate('planner.availability.errors.unauthorized', 'Unauthorized') })
@@ -215,10 +217,3 @@ function resolveDateSet(input: { date?: string; dates?: string[] }): Set<string>
215
217
  }
216
218
  return dates
217
219
  }
218
-
219
- function formatDateKey(value: Date): string {
220
- const year = value.getFullYear()
221
- const month = String(value.getMonth() + 1).padStart(2, '0')
222
- const day = String(value.getDate()).padStart(2, '0')
223
- return `${year}-${month}-${day}`
224
- }