@open-mercato/core 0.10.1-develop.7724.1.95e4e7d38e → 0.10.1-develop.7726.1.a447cc5525
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/planner/api/availability-date-specific.js +2 -7
- package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/commands/availability-date-specific.js +14 -31
- package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +8 -22
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/lib/availabilityMerge.js +10 -18
- package/dist/modules/planner/lib/availabilityMerge.js.map +2 -2
- package/dist/modules/planner/lib/availabilityTimezone.js +161 -0
- package/dist/modules/planner/lib/availabilityTimezone.js.map +7 -0
- package/package.json +8 -7
- package/src/modules/planner/api/availability-date-specific.ts +5 -10
- package/src/modules/planner/commands/availability-date-specific.ts +14 -33
- package/src/modules/planner/commands/availability-weekly.ts +15 -24
- package/src/modules/planner/lib/availabilityMerge.ts +13 -19
- package/src/modules/planner/lib/availabilityTimezone.ts +242 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -9,6 +9,7 @@ import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/er
|
|
|
9
9
|
import { parseScopedCommandInput } from "@open-mercato/shared/lib/api/scoped";
|
|
10
10
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
11
11
|
import { plannerAvailabilityDateSpecificReplaceSchema } from "../data/validators.js";
|
|
12
|
+
import { zonedDateKey } from "../lib/availabilityTimezone.js";
|
|
12
13
|
import { serializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
|
|
13
14
|
import {
|
|
14
15
|
runCrudMutationGuardAfterSuccess,
|
|
@@ -80,7 +81,7 @@ async function POST(req) {
|
|
|
80
81
|
const blocked = rules.some((rule) => {
|
|
81
82
|
const window = parseAvailabilityRuleWindow(rule);
|
|
82
83
|
if (window.repeat !== "once") return false;
|
|
83
|
-
return dateSet.has(
|
|
84
|
+
return dateSet.has(zonedDateKey(window.startAt, rule.timezone));
|
|
84
85
|
});
|
|
85
86
|
if (blocked) {
|
|
86
87
|
throw new CrudHttpError(403, { error: translate("planner.availability.errors.unauthorized", "Unauthorized") });
|
|
@@ -190,12 +191,6 @@ function resolveDateSet(input) {
|
|
|
190
191
|
}
|
|
191
192
|
return dates;
|
|
192
193
|
}
|
|
193
|
-
function formatDateKey(value) {
|
|
194
|
-
const year = value.getFullYear();
|
|
195
|
-
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
196
|
-
const day = String(value.getDate()).padStart(2, "0");
|
|
197
|
-
return `${year}-${month}-${day}`;
|
|
198
|
-
}
|
|
199
194
|
export {
|
|
200
195
|
POST,
|
|
201
196
|
metadata,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/planner/api/availability-date-specific.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { plannerAvailabilityDateSpecificReplaceSchema } from '../data/validators'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport { PlannerAvailabilityRule } from '../data/entities'\nimport { parseAvailabilityRuleWindow } from '../lib/availabilitySchedule'\nimport { assertAvailabilityWriteAccess, resolveAvailabilityActorId } from './access'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('planner').child({ component: 'availability' })\n\nexport const metadata = {\n POST: { requireAuth: true },\n}\
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,yCAAyC;AAElD,SAAS,2BAA2B;AACpC,SAAS,eAAe,uBAAuB;AAC/C,SAAS,+BAA+B;AACxC,SAAS,0BAA0B;AACnC,SAAS,oDAAoD;AAC7D,SAAS,kCAAkC;AAC3C;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,+BAA+B;AACxC,SAAS,mCAAmC;AAC5C,SAAS,+BAA+B,kCAAkC;AAC1E,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,SAAS,EAAE,MAAM,EAAE,WAAW,eAAe,CAAC;AAEnE,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,KAAK;AAC5B;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { plannerAvailabilityDateSpecificReplaceSchema } from '../data/validators'\nimport { zonedDateKey } from '../lib/availabilityTimezone'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport { PlannerAvailabilityRule } from '../data/entities'\nimport { parseAvailabilityRuleWindow } from '../lib/availabilitySchedule'\nimport { assertAvailabilityWriteAccess, resolveAvailabilityActorId } from './access'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('planner').child({ component: 'availability' })\n\nexport const metadata = {\n POST: { requireAuth: true },\n}\ntype RequestContext = {\n ctx: CommandRuntimeContext\n}\nasync function resolveRequestContext(req: Request): Promise<RequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('planner.availability.errors.unauthorized', 'Unauthorized') })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth) ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, {\n error: translate('planner.availability.errors.organizationRequired', 'Organization context is required'),\n })\n }\n\n const ctx: CommandRuntimeContext = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n }\n\n return { ctx }\n}\n\nexport async function POST(req: Request) {\n try {\n const { ctx } = await resolveRequestContext(req)\n const { translate } = await resolveTranslations()\n const payload = await req.json().catch(() => ({}))\n const normalized = normalizeDateSpecificPayload(payload)\n const input = parseScopedCommandInput(plannerAvailabilityDateSpecificReplaceSchema, normalized, ctx, translate)\n const isUnavailability = input.kind === 'unavailability' || input.isAvailable === false\n const access = await assertAvailabilityWriteAccess(\n ctx,\n { subjectType: input.subjectType, subjectId: input.subjectId, requiresUnavailability: isUnavailability },\n translate,\n )\n if (!access.canManageAll && !access.canManageUnavailability) {\n const dateSet = resolveDateSet(input)\n if (dateSet.size) {\n const em = ctx.container.resolve('em') as EntityManager\n const rules = await findWithDecryption(\n em,\n PlannerAvailabilityRule,\n {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n subjectType: input.subjectType,\n subjectId: input.subjectId,\n kind: 'unavailability',\n deletedAt: null,\n },\n undefined,\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n const blocked = rules.some((rule) => {\n const window = parseAvailabilityRuleWindow(rule)\n if (window.repeat !== 'once') return false\n // Resolved in the rule's own zone so this gate agrees with\n // `planner.availability.date-specific.replace` and does not depend\n // on the host clock.\n return dateSet.has(zonedDateKey(window.startAt, rule.timezone))\n })\n if (blocked) {\n throw new CrudHttpError(403, { error: translate('planner.availability.errors.unauthorized', 'Unauthorized') })\n }\n }\n }\n const guardInput = {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: resolveAvailabilityActorId(ctx.auth),\n resourceKind: 'planner.availability',\n resourceId: input.subjectId,\n operation: 'custom' as const,\n requestMethod: req.method,\n requestHeaders: req.headers,\n }\n const guardResult = await validateCrudMutationGuard(ctx.container, { ...guardInput, mutationPayload: input })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const { logEntry } = await commandBus.execute('planner.availability.date-specific.replace', { input, ctx })\n if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(ctx.container, { ...guardInput, metadata: guardResult.metadata ?? null })\n }\n const response = NextResponse.json({ ok: true })\n if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {\n response.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'planner.availability',\n resourceId: logEntry.resourceId ?? null,\n executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : undefined,\n })\n )\n }\n return response\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n logger.error('Date-specific availability replace failed', { err })\n return NextResponse.json(\n { error: translate('planner.availability.errors.updateDateSpecific', 'Failed to save date-specific availability.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi = {\n tag: 'Planner',\n summary: 'Replace date-specific availability',\n methods: {\n POST: {\n summary: 'Replace date-specific availability',\n description: 'Replaces date-specific availability rules for the subject in a single request.',\n requestBody: {\n contentType: 'application/json',\n schema: plannerAvailabilityDateSpecificReplaceSchema,\n },\n responses: [\n { status: 200, description: 'Date-specific availability updated', schema: z.object({ ok: z.literal(true) }) },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n {\n status: 409,\n description: 'Conflict \u2014 the subject/date availability was modified by another edit',\n schema: z.object({\n error: z.string(),\n code: z.string(),\n currentUpdatedAt: z.string(),\n expectedUpdatedAt: z.string(),\n }),\n },\n ],\n },\n },\n}\n\ntype DateSpecificPayload = {\n date?: string\n dates?: string[]\n kind?: string\n isAvailable?: boolean\n [key: string]: unknown\n}\n\nfunction normalizeDateSpecificPayload(payload: unknown): DateSpecificPayload {\n if (!payload || typeof payload !== 'object') return {}\n const data = { ...(payload as Record<string, unknown>) } as DateSpecificPayload\n if (!data.date && Array.isArray(data.dates) && data.dates.length > 0) {\n const first = data.dates.find((value) => typeof value === 'string' && value.length > 0)\n if (first) data.date = first\n }\n if (data.isAvailable === undefined && typeof data.kind === 'string') {\n data.isAvailable = data.kind !== 'unavailability'\n }\n return data\n}\n\nfunction resolveDateSet(input: { date?: string; dates?: string[] }): Set<string> {\n const dates = new Set<string>()\n if (typeof input.date === 'string' && input.date.length > 0) {\n dates.add(input.date)\n }\n if (Array.isArray(input.dates)) {\n input.dates.forEach((value) => {\n if (typeof value === 'string' && value.length > 0) {\n dates.add(value)\n }\n })\n }\n return dates\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { registerCommand } from "@open-mercato/shared/lib/commands";
|
|
2
2
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
3
3
|
import { parseAvailabilityRuleWindow } from "@open-mercato/core/modules/planner/lib/availabilitySchedule";
|
|
4
|
+
import { warnOnUnresolvableTimeZone, zonedDateKey, zonedDayBounds, zonedWallTimeToInstant } from "@open-mercato/core/modules/planner/lib/availabilityTimezone";
|
|
4
5
|
import { PlannerAvailabilityRule } from "../data/entities.js";
|
|
5
6
|
import {
|
|
6
7
|
plannerAvailabilityDateSpecificReplaceSchema
|
|
@@ -17,21 +18,9 @@ function resolveAggregateLockVersion(rules) {
|
|
|
17
18
|
}
|
|
18
19
|
return latest;
|
|
19
20
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
24
|
-
return { hours, minutes };
|
|
25
|
-
}
|
|
26
|
-
function toDateForDay(value, time) {
|
|
27
|
-
if (!value) return null;
|
|
28
|
-
const parsed = parseTimeInput(time);
|
|
29
|
-
if (!parsed) return null;
|
|
30
|
-
const parts = value.split("-").map((part) => Number(part));
|
|
31
|
-
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) return null;
|
|
32
|
-
const [year, month, day] = parts;
|
|
33
|
-
const date = new Date(year, month - 1, day, parsed.hours, parsed.minutes, 0, 0);
|
|
34
|
-
return Number.isNaN(date.getTime()) ? null : date;
|
|
21
|
+
function toDateForDay(dateKey, time, timeZone) {
|
|
22
|
+
if (!dateKey) return null;
|
|
23
|
+
return zonedWallTimeToInstant(dateKey, time, timeZone);
|
|
35
24
|
}
|
|
36
25
|
function formatDuration(minutes) {
|
|
37
26
|
const clamped = Math.max(1, minutes);
|
|
@@ -49,17 +38,10 @@ function buildAvailabilityRrule(start, end) {
|
|
|
49
38
|
DURATION:${duration}
|
|
50
39
|
RRULE:FREQ=DAILY;COUNT=1`;
|
|
51
40
|
}
|
|
52
|
-
function buildFullDayRrule(date) {
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
55
|
-
|
|
56
|
-
return buildAvailabilityRrule(start, end);
|
|
57
|
-
}
|
|
58
|
-
function formatDateKey(value) {
|
|
59
|
-
const year = value.getFullYear();
|
|
60
|
-
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
61
|
-
const day = String(value.getDate()).padStart(2, "0");
|
|
62
|
-
return `${year}-${month}-${day}`;
|
|
41
|
+
function buildFullDayRrule(date, timeZone) {
|
|
42
|
+
const bounds = zonedDayBounds(date, timeZone);
|
|
43
|
+
if (!bounds) return null;
|
|
44
|
+
return buildAvailabilityRrule(bounds.start, bounds.end);
|
|
63
45
|
}
|
|
64
46
|
function toAvailabilityRuleSnapshot(record) {
|
|
65
47
|
return {
|
|
@@ -90,7 +72,7 @@ async function loadDateSpecificSnapshots(em, params) {
|
|
|
90
72
|
return existing.filter((rule) => {
|
|
91
73
|
const window = parseAvailabilityRuleWindow(rule);
|
|
92
74
|
if (window.repeat !== "once") return false;
|
|
93
|
-
return params.dates.has(
|
|
75
|
+
return params.dates.has(zonedDateKey(window.startAt, rule.timezone));
|
|
94
76
|
}).map(toAvailabilityRuleSnapshot);
|
|
95
77
|
}
|
|
96
78
|
async function restoreAvailabilityRuleFromSnapshot(em, snapshot) {
|
|
@@ -146,6 +128,7 @@ const replaceDateSpecificAvailabilityCommand = {
|
|
|
146
128
|
},
|
|
147
129
|
async execute(input, ctx) {
|
|
148
130
|
const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input);
|
|
131
|
+
warnOnUnresolvableTimeZone(parsed.timezone, "planner.availability.date-specific.replace");
|
|
149
132
|
ensureTenantScope(ctx, parsed.tenantId);
|
|
150
133
|
ensureOrganizationScope(ctx, parsed.organizationId);
|
|
151
134
|
const dates = resolveDateSet(parsed);
|
|
@@ -169,7 +152,7 @@ const replaceDateSpecificAvailabilityCommand = {
|
|
|
169
152
|
const toDelete = existing.filter((rule) => {
|
|
170
153
|
const window = parseAvailabilityRuleWindow(rule);
|
|
171
154
|
if (window.repeat !== "once") return false;
|
|
172
|
-
return dates.has(
|
|
155
|
+
return dates.has(zonedDateKey(window.startAt, rule.timezone));
|
|
173
156
|
});
|
|
174
157
|
enforceCommandOptimisticLock({
|
|
175
158
|
resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,
|
|
@@ -188,7 +171,7 @@ const replaceDateSpecificAvailabilityCommand = {
|
|
|
188
171
|
if (!dates.size) return;
|
|
189
172
|
if (!isAvailable) {
|
|
190
173
|
dates.forEach((date) => {
|
|
191
|
-
const rrule = buildFullDayRrule(date);
|
|
174
|
+
const rrule = buildFullDayRrule(date, parsed.timezone);
|
|
192
175
|
if (!rrule) return;
|
|
193
176
|
const record = trx.create(PlannerAvailabilityRule, {
|
|
194
177
|
tenantId: parsed.tenantId,
|
|
@@ -210,8 +193,8 @@ const replaceDateSpecificAvailabilityCommand = {
|
|
|
210
193
|
} else {
|
|
211
194
|
dates.forEach((date) => {
|
|
212
195
|
windows.forEach((window) => {
|
|
213
|
-
const start = toDateForDay(date, window.start);
|
|
214
|
-
const end = toDateForDay(date, window.end);
|
|
196
|
+
const start = toDateForDay(date, window.start, parsed.timezone);
|
|
197
|
+
const end = toDateForDay(date, window.end, parsed.timezone);
|
|
215
198
|
if (!start || !end || start >= end) return;
|
|
216
199
|
const rrule = buildAvailabilityRrule(start, end);
|
|
217
200
|
const record = trx.create(PlannerAvailabilityRule, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/planner/commands/availability-date-specific.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'\nimport { PlannerAvailabilityRule } from '../data/entities'\nimport {\n plannerAvailabilityDateSpecificReplaceSchema,\n type PlannerAvailabilityDateSpecificReplaceInput,\n} from '../data/validators'\nimport { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'\n\nconst AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'\n\n/**\n * The date-specific aggregate (the one-off rules for a subject/date) has no\n * single parent row, so its optimistic-lock version is the latest `updated_at`\n * of the rules being replaced. The editor sends that token via the extension\n * header; an empty set means \"nothing to clobber\" \u2192 no current version \u2192 the\n * guard is a no-op (strictly additive, fail-open).\n */\nfunction resolveAggregateLockVersion(rules: PlannerAvailabilityRule[]): Date | null {\n let latest: Date | null = null\n for (const rule of rules) {\n const value = rule.updatedAt\n if (!value || Number.isNaN(value.getTime())) continue\n if (!latest || value.getTime() > latest.getTime()) latest = value\n }\n return latest\n}\n\ntype AvailabilityRuleSnapshot = {\n id: string\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n timezone: string\n rrule: string\n exdates: string[]\n kind: PlannerAvailabilityKind\n note: string | null\n unavailabilityReasonEntryId: string | null\n unavailabilityReasonValue: string | null\n deletedAt: Date | null\n}\n\ntype DateSpecificUndoPayload = {\n before: AvailabilityRuleSnapshot[]\n after: AvailabilityRuleSnapshot[]\n}\n\nfunction parseTimeInput(value: string): { hours: number; minutes: number } | null {\n const [hours, minutes] = 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\nfunction toDateForDay(value: string, time: string): Date | null {\n if (!value) return null\n const parsed = parseTimeInput(time)\n if (!parsed) return null\n const parts = value.split('-').map((part) => Number(part))\n if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) return null\n const [year, month, day] = parts\n const date = new Date(year, month - 1, day, parsed.hours, parsed.minutes, 0, 0)\n return Number.isNaN(date.getTime()) ? null : date\n}\n\nfunction formatDuration(minutes: number): string {\n const clamped = Math.max(1, minutes)\n const hours = Math.floor(clamped / 60)\n const mins = clamped % 60\n if (hours > 0 && mins > 0) return `PT${hours}H${mins}M`\n if (hours > 0) return `PT${hours}H`\n return `PT${mins}M`\n}\n\nfunction buildAvailabilityRrule(start: Date, end: Date): string {\n const dtStart = start.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'\n const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))\n const duration = formatDuration(durationMinutes)\n return `DTSTART:${dtStart}\\nDURATION:${duration}\\nRRULE:FREQ=DAILY;COUNT=1`\n}\n\nfunction buildFullDayRrule(date: string): string | null {\n const start = toDateForDay(date, '00:00')\n if (!start) return null\n const end = new Date(start.getTime() + 24 * 60 * 60 * 1000)\n return buildAvailabilityRrule(start, end)\n}\n\nfunction formatDateKey(value: Date): string {\n const year = value.getFullYear()\n const month = String(value.getMonth() + 1).padStart(2, '0')\n const day = String(value.getDate()).padStart(2, '0')\n return `${year}-${month}-${day}`\n}\n\nfunction toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): AvailabilityRuleSnapshot {\n return {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n subjectType: record.subjectType,\n subjectId: record.subjectId,\n timezone: record.timezone,\n rrule: record.rrule,\n exdates: [...(record.exdates ?? [])],\n kind: record.kind,\n note: record.note ?? null,\n unavailabilityReasonEntryId: record.unavailabilityReasonEntryId ?? null,\n unavailabilityReasonValue: record.unavailabilityReasonValue ?? null,\n deletedAt: record.deletedAt ?? null,\n }\n}\n\nasync function loadDateSpecificSnapshots(\n em: EntityManager,\n params: {\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n dates: Set<string>\n }\n): Promise<AvailabilityRuleSnapshot[]> {\n if (!params.dates.size) return []\n const existing = await em.find(PlannerAvailabilityRule, {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n subjectType: params.subjectType,\n subjectId: params.subjectId,\n deletedAt: null,\n })\n return existing\n .filter((rule) => {\n const window = parseAvailabilityRuleWindow(rule)\n if (window.repeat !== 'once') return false\n return params.dates.has(formatDateKey(window.startAt))\n })\n .map(toAvailabilityRuleSnapshot)\n}\n\nasync function restoreAvailabilityRuleFromSnapshot(em: EntityManager, snapshot: AvailabilityRuleSnapshot): Promise<void> {\n let record = await em.findOne(PlannerAvailabilityRule, { id: snapshot.id })\n if (!record) {\n record = em.create(PlannerAvailabilityRule, {\n id: snapshot.id,\n tenantId: snapshot.tenantId,\n organizationId: snapshot.organizationId,\n subjectType: snapshot.subjectType,\n subjectId: snapshot.subjectId,\n timezone: snapshot.timezone,\n rrule: snapshot.rrule,\n exdates: snapshot.exdates ?? [],\n kind: snapshot.kind ?? 'availability',\n note: snapshot.note ?? null,\n unavailabilityReasonEntryId: snapshot.unavailabilityReasonEntryId ?? null,\n unavailabilityReasonValue: snapshot.unavailabilityReasonValue ?? null,\n createdAt: new Date(),\n updatedAt: new Date(),\n deletedAt: snapshot.deletedAt ?? null,\n })\n em.persist(record)\n } else {\n record.subjectType = snapshot.subjectType\n record.subjectId = snapshot.subjectId\n record.timezone = snapshot.timezone\n record.rrule = snapshot.rrule\n record.exdates = snapshot.exdates ?? []\n record.kind = snapshot.kind ?? 'availability'\n record.note = snapshot.note ?? null\n record.unavailabilityReasonEntryId = snapshot.unavailabilityReasonEntryId ?? null\n record.unavailabilityReasonValue = snapshot.unavailabilityReasonValue ?? null\n record.deletedAt = snapshot.deletedAt ?? null\n }\n}\n\nconst replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailabilityDateSpecificReplaceInput, { ok: true }> = {\n id: 'planner.availability.date-specific.replace',\n async prepare(input, ctx) {\n const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const dates = resolveDateSet(parsed)\n const em = (ctx.container.resolve('em') as EntityManager)\n const before = await loadDateSpecificSnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n dates,\n })\n return { before }\n },\n async execute(input, ctx) {\n const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const dates = resolveDateSet(parsed)\n const windows = parsed.windows ?? []\n const kind = parsed.kind ?? (parsed.isAvailable === false ? 'unavailability' : 'availability')\n const isAvailable = kind !== 'unavailability'\n const note = typeof parsed.note === 'string' && parsed.note.trim().length ? parsed.note.trim() : null\n const unavailabilityReasonEntryId = isAvailable ? null : parsed.unavailabilityReasonEntryId ?? null\n const unavailabilityReasonValue = isAvailable ? null : parsed.unavailabilityReasonValue ?? null\n const now = new Date()\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n await em.transactional(async (trx) => {\n if (dates.size) {\n const existing = await trx.find(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n deletedAt: null,\n })\n const toDelete = existing.filter((rule) => {\n const window = parseAvailabilityRuleWindow(rule)\n if (window.repeat !== 'once') return false\n return dates.has(formatDateKey(window.startAt))\n })\n enforceCommandOptimisticLock({\n resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,\n resourceId: `${parsed.subjectType}:${parsed.subjectId}`,\n current: resolveAggregateLockVersion(toDelete),\n request: ctx.request ?? null,\n })\n toDelete.forEach((rule) => {\n rule.deletedAt = now\n rule.updatedAt = now\n })\n if (toDelete.length) {\n trx.persist(toDelete)\n }\n }\n\n if (!dates.size) return\n\n if (!isAvailable) {\n dates.forEach((date) => {\n const rrule = buildFullDayRrule(date)\n if (!rrule) return\n const record = trx.create(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n timezone: parsed.timezone,\n rrule,\n exdates: [],\n kind: 'unavailability',\n note,\n unavailabilityReasonEntryId,\n unavailabilityReasonValue,\n createdAt: now,\n updatedAt: now,\n })\n trx.persist(record)\n })\n } else {\n dates.forEach((date) => {\n windows.forEach((window) => {\n const start = toDateForDay(date, window.start)\n const end = toDateForDay(date, window.end)\n if (!start || !end || start >= end) return\n const rrule = buildAvailabilityRrule(start, end)\n const record = trx.create(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n timezone: parsed.timezone,\n rrule,\n exdates: [],\n kind: 'availability',\n note: null,\n unavailabilityReasonEntryId: null,\n unavailabilityReasonValue: null,\n createdAt: now,\n updatedAt: now,\n })\n trx.persist(record)\n })\n })\n }\n\n await trx.flush()\n })\n return { ok: true }\n },\n buildLog: async ({ input, snapshots, ctx }) => {\n const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)\n const dates = resolveDateSet(parsed)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const after = await loadDateSpecificSnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n dates,\n })\n const before = (snapshots.before as AvailabilityRuleSnapshot[] | undefined) ?? []\n const { translate } = await resolveTranslations()\n return {\n actionLabel: translate('planner.audit.availability.dateSpecific.replace', 'Replace date-specific availability'),\n resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,\n resourceId: null,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n snapshotBefore: before,\n snapshotAfter: after,\n payload: {\n undo: {\n before,\n after,\n } satisfies DateSpecificUndoPayload,\n },\n }\n },\n undo: async ({ logEntry, ctx }) => {\n const payload = extractUndoPayload<DateSpecificUndoPayload>(logEntry)\n const before = payload?.before ?? []\n const after = payload?.after ?? []\n if (!before.length && !after.length) return\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n await em.transactional(async (trx) => {\n if (after.length) {\n const ids = after.map((rule) => rule.id)\n const records = await trx.find(PlannerAvailabilityRule, { id: { $in: ids } })\n records.forEach((record) => {\n record.deletedAt = new Date()\n })\n if (records.length) trx.persist(records)\n }\n\n for (const snapshot of before) {\n await restoreAvailabilityRuleFromSnapshot(trx, { ...snapshot, deletedAt: null })\n }\n\n await trx.flush()\n })\n },\n}\n\nfunction resolveDateSet(input: PlannerAvailabilityDateSpecificReplaceInput): Set<string> {\n const dates = Array.isArray(input.dates) ? input.dates.filter((value) => typeof value === 'string' && value.length > 0) : []\n if (dates.length) return new Set(dates)\n if (typeof input.date === 'string' && input.date.length > 0) return new Set([input.date])\n return new Set()\n}\n\nregisterCommand(replaceDateSpecificAvailabilityCommand)\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,uBAAuB;AAEhC,SAAS,2BAA2B;AACpC,SAAS,mCAAmC;AAC5C,SAAS,+BAA+B;AACxC;AAAA,EACE;AAAA,OAEK;AACP,SAAS,yBAAyB,mBAAmB,0BAA0B;AAC/E,SAAS,oCAAoC;AAG7C,MAAM,kCAAkC;AASxC,SAAS,4BAA4B,OAA+C;AAClF,MAAI,SAAsB;AAC1B,aAAW,QAAQ,OAAO;AACxB,UAAM,QAAQ,KAAK;AACnB,QAAI,CAAC,SAAS,OAAO,MAAM,MAAM,QAAQ,CAAC,EAAG;AAC7C,QAAI,CAAC,UAAU,MAAM,QAAQ,IAAI,OAAO,QAAQ,EAAG,UAAS;AAAA,EAC9D;AACA,SAAO;AACT;AAuBA,SAAS,
|
|
4
|
+
"sourcesContent": ["import type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'\nimport { warnOnUnresolvableTimeZone, zonedDateKey, zonedDayBounds, zonedWallTimeToInstant } from '@open-mercato/core/modules/planner/lib/availabilityTimezone'\nimport { PlannerAvailabilityRule } from '../data/entities'\nimport {\n plannerAvailabilityDateSpecificReplaceSchema,\n type PlannerAvailabilityDateSpecificReplaceInput,\n} from '../data/validators'\nimport { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'\n\nconst AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'\n\n/**\n * The date-specific aggregate (the one-off rules for a subject/date) has no\n * single parent row, so its optimistic-lock version is the latest `updated_at`\n * of the rules being replaced. The editor sends that token via the extension\n * header; an empty set means \"nothing to clobber\" \u2192 no current version \u2192 the\n * guard is a no-op (strictly additive, fail-open).\n */\nfunction resolveAggregateLockVersion(rules: PlannerAvailabilityRule[]): Date | null {\n let latest: Date | null = null\n for (const rule of rules) {\n const value = rule.updatedAt\n if (!value || Number.isNaN(value.getTime())) continue\n if (!latest || value.getTime() > latest.getTime()) latest = value\n }\n return latest\n}\n\ntype AvailabilityRuleSnapshot = {\n id: string\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n timezone: string\n rrule: string\n exdates: string[]\n kind: PlannerAvailabilityKind\n note: string | null\n unavailabilityReasonEntryId: string | null\n unavailabilityReasonValue: string | null\n deletedAt: Date | null\n}\n\ntype DateSpecificUndoPayload = {\n before: AvailabilityRuleSnapshot[]\n after: AvailabilityRuleSnapshot[]\n}\n\nfunction toDateForDay(dateKey: string, time: string, timeZone: string): Date | null {\n if (!dateKey) return null\n return zonedWallTimeToInstant(dateKey, time, timeZone)\n}\n\nfunction formatDuration(minutes: number): string {\n const clamped = Math.max(1, minutes)\n const hours = Math.floor(clamped / 60)\n const mins = clamped % 60\n if (hours > 0 && mins > 0) return `PT${hours}H${mins}M`\n if (hours > 0) return `PT${hours}H`\n return `PT${mins}M`\n}\n\nfunction buildAvailabilityRrule(start: Date, end: Date): string {\n const dtStart = start.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'\n const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))\n const duration = formatDuration(durationMinutes)\n return `DTSTART:${dtStart}\\nDURATION:${duration}\\nRRULE:FREQ=DAILY;COUNT=1`\n}\n\nfunction buildFullDayRrule(date: string, timeZone: string): string | null {\n const bounds = zonedDayBounds(date, timeZone)\n if (!bounds) return null\n return buildAvailabilityRrule(bounds.start, bounds.end)\n}\n\nfunction toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): AvailabilityRuleSnapshot {\n return {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n subjectType: record.subjectType,\n subjectId: record.subjectId,\n timezone: record.timezone,\n rrule: record.rrule,\n exdates: [...(record.exdates ?? [])],\n kind: record.kind,\n note: record.note ?? null,\n unavailabilityReasonEntryId: record.unavailabilityReasonEntryId ?? null,\n unavailabilityReasonValue: record.unavailabilityReasonValue ?? null,\n deletedAt: record.deletedAt ?? null,\n }\n}\n\nasync function loadDateSpecificSnapshots(\n em: EntityManager,\n params: {\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n dates: Set<string>\n }\n): Promise<AvailabilityRuleSnapshot[]> {\n if (!params.dates.size) return []\n const existing = await em.find(PlannerAvailabilityRule, {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n subjectType: params.subjectType,\n subjectId: params.subjectId,\n deletedAt: null,\n })\n return existing\n .filter((rule) => {\n const window = parseAvailabilityRuleWindow(rule)\n if (window.repeat !== 'once') return false\n return params.dates.has(zonedDateKey(window.startAt, rule.timezone))\n })\n .map(toAvailabilityRuleSnapshot)\n}\n\nasync function restoreAvailabilityRuleFromSnapshot(em: EntityManager, snapshot: AvailabilityRuleSnapshot): Promise<void> {\n let record = await em.findOne(PlannerAvailabilityRule, { id: snapshot.id })\n if (!record) {\n record = em.create(PlannerAvailabilityRule, {\n id: snapshot.id,\n tenantId: snapshot.tenantId,\n organizationId: snapshot.organizationId,\n subjectType: snapshot.subjectType,\n subjectId: snapshot.subjectId,\n timezone: snapshot.timezone,\n rrule: snapshot.rrule,\n exdates: snapshot.exdates ?? [],\n kind: snapshot.kind ?? 'availability',\n note: snapshot.note ?? null,\n unavailabilityReasonEntryId: snapshot.unavailabilityReasonEntryId ?? null,\n unavailabilityReasonValue: snapshot.unavailabilityReasonValue ?? null,\n createdAt: new Date(),\n updatedAt: new Date(),\n deletedAt: snapshot.deletedAt ?? null,\n })\n em.persist(record)\n } else {\n record.subjectType = snapshot.subjectType\n record.subjectId = snapshot.subjectId\n record.timezone = snapshot.timezone\n record.rrule = snapshot.rrule\n record.exdates = snapshot.exdates ?? []\n record.kind = snapshot.kind ?? 'availability'\n record.note = snapshot.note ?? null\n record.unavailabilityReasonEntryId = snapshot.unavailabilityReasonEntryId ?? null\n record.unavailabilityReasonValue = snapshot.unavailabilityReasonValue ?? null\n record.deletedAt = snapshot.deletedAt ?? null\n }\n}\n\nconst replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailabilityDateSpecificReplaceInput, { ok: true }> = {\n id: 'planner.availability.date-specific.replace',\n async prepare(input, ctx) {\n const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const dates = resolveDateSet(parsed)\n const em = (ctx.container.resolve('em') as EntityManager)\n const before = await loadDateSpecificSnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n dates,\n })\n return { before }\n },\n async execute(input, ctx) {\n const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)\n warnOnUnresolvableTimeZone(parsed.timezone, 'planner.availability.date-specific.replace')\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const dates = resolveDateSet(parsed)\n const windows = parsed.windows ?? []\n const kind = parsed.kind ?? (parsed.isAvailable === false ? 'unavailability' : 'availability')\n const isAvailable = kind !== 'unavailability'\n const note = typeof parsed.note === 'string' && parsed.note.trim().length ? parsed.note.trim() : null\n const unavailabilityReasonEntryId = isAvailable ? null : parsed.unavailabilityReasonEntryId ?? null\n const unavailabilityReasonValue = isAvailable ? null : parsed.unavailabilityReasonValue ?? null\n const now = new Date()\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n await em.transactional(async (trx) => {\n if (dates.size) {\n const existing = await trx.find(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n deletedAt: null,\n })\n const toDelete = existing.filter((rule) => {\n const window = parseAvailabilityRuleWindow(rule)\n if (window.repeat !== 'once') return false\n return dates.has(zonedDateKey(window.startAt, rule.timezone))\n })\n enforceCommandOptimisticLock({\n resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,\n resourceId: `${parsed.subjectType}:${parsed.subjectId}`,\n current: resolveAggregateLockVersion(toDelete),\n request: ctx.request ?? null,\n })\n toDelete.forEach((rule) => {\n rule.deletedAt = now\n rule.updatedAt = now\n })\n if (toDelete.length) {\n trx.persist(toDelete)\n }\n }\n\n if (!dates.size) return\n\n if (!isAvailable) {\n dates.forEach((date) => {\n const rrule = buildFullDayRrule(date, parsed.timezone)\n if (!rrule) return\n const record = trx.create(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n timezone: parsed.timezone,\n rrule,\n exdates: [],\n kind: 'unavailability',\n note,\n unavailabilityReasonEntryId,\n unavailabilityReasonValue,\n createdAt: now,\n updatedAt: now,\n })\n trx.persist(record)\n })\n } else {\n dates.forEach((date) => {\n windows.forEach((window) => {\n const start = toDateForDay(date, window.start, parsed.timezone)\n const end = toDateForDay(date, window.end, parsed.timezone)\n if (!start || !end || start >= end) return\n const rrule = buildAvailabilityRrule(start, end)\n const record = trx.create(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n timezone: parsed.timezone,\n rrule,\n exdates: [],\n kind: 'availability',\n note: null,\n unavailabilityReasonEntryId: null,\n unavailabilityReasonValue: null,\n createdAt: now,\n updatedAt: now,\n })\n trx.persist(record)\n })\n })\n }\n\n await trx.flush()\n })\n return { ok: true }\n },\n buildLog: async ({ input, snapshots, ctx }) => {\n const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)\n const dates = resolveDateSet(parsed)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const after = await loadDateSpecificSnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n dates,\n })\n const before = (snapshots.before as AvailabilityRuleSnapshot[] | undefined) ?? []\n const { translate } = await resolveTranslations()\n return {\n actionLabel: translate('planner.audit.availability.dateSpecific.replace', 'Replace date-specific availability'),\n resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,\n resourceId: null,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n snapshotBefore: before,\n snapshotAfter: after,\n payload: {\n undo: {\n before,\n after,\n } satisfies DateSpecificUndoPayload,\n },\n }\n },\n undo: async ({ logEntry, ctx }) => {\n const payload = extractUndoPayload<DateSpecificUndoPayload>(logEntry)\n const before = payload?.before ?? []\n const after = payload?.after ?? []\n if (!before.length && !after.length) return\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n await em.transactional(async (trx) => {\n if (after.length) {\n const ids = after.map((rule) => rule.id)\n const records = await trx.find(PlannerAvailabilityRule, { id: { $in: ids } })\n records.forEach((record) => {\n record.deletedAt = new Date()\n })\n if (records.length) trx.persist(records)\n }\n\n for (const snapshot of before) {\n await restoreAvailabilityRuleFromSnapshot(trx, { ...snapshot, deletedAt: null })\n }\n\n await trx.flush()\n })\n },\n}\n\nfunction resolveDateSet(input: PlannerAvailabilityDateSpecificReplaceInput): Set<string> {\n const dates = Array.isArray(input.dates) ? input.dates.filter((value) => typeof value === 'string' && value.length > 0) : []\n if (dates.length) return new Set(dates)\n if (typeof input.date === 'string' && input.date.length > 0) return new Set([input.date])\n return new Set()\n}\n\nregisterCommand(replaceDateSpecificAvailabilityCommand)\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registerCommand } from "@open-mercato/shared/lib/commands";
|
|
2
2
|
import { parseAvailabilityRuleWindow } from "@open-mercato/core/modules/planner/lib/availabilitySchedule";
|
|
3
|
+
import { warnOnUnresolvableTimeZone, zonedWeeklyAnchor, zonedWeekday } from "@open-mercato/core/modules/planner/lib/availabilityTimezone";
|
|
3
4
|
import { PlannerAvailabilityRule, PlannerAvailabilityRuleSet } from "../data/entities.js";
|
|
4
5
|
import {
|
|
5
6
|
plannerAvailabilityWeeklyReplaceSchema
|
|
@@ -20,22 +21,6 @@ const availabilityRuleSetCrudIndexer = {
|
|
|
20
21
|
};
|
|
21
22
|
const AVAILABILITY_RULE_SET_RESOURCE_KIND = "planner.availability.rule.set";
|
|
22
23
|
const DAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
|
|
23
|
-
function parseTimeInput(value) {
|
|
24
|
-
const [hours, minutes] = value.split(":").map((part) => Number(part));
|
|
25
|
-
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
|
26
|
-
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
27
|
-
return { hours, minutes };
|
|
28
|
-
}
|
|
29
|
-
function toDateForWeekday(weekday, time) {
|
|
30
|
-
const parsed = parseTimeInput(time);
|
|
31
|
-
if (!parsed) return null;
|
|
32
|
-
const now = /* @__PURE__ */ new Date();
|
|
33
|
-
const base = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
34
|
-
const diff = (weekday - base.getDay() + 7) % 7;
|
|
35
|
-
const target = new Date(base.getTime() + diff * 24 * 60 * 60 * 1e3);
|
|
36
|
-
target.setHours(parsed.hours, parsed.minutes, 0, 0);
|
|
37
|
-
return target;
|
|
38
|
-
}
|
|
39
24
|
function formatDuration(minutes) {
|
|
40
25
|
const clamped = Math.max(1, minutes);
|
|
41
26
|
const hours = Math.floor(clamped / 60);
|
|
@@ -44,11 +29,11 @@ function formatDuration(minutes) {
|
|
|
44
29
|
if (hours > 0) return `PT${hours}H`;
|
|
45
30
|
return `PT${mins}M`;
|
|
46
31
|
}
|
|
47
|
-
function buildWeeklyRrule(start, end) {
|
|
32
|
+
function buildWeeklyRrule(start, end, timeZone) {
|
|
48
33
|
const dtStart = start.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
|
49
34
|
const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 6e4));
|
|
50
35
|
const duration = formatDuration(durationMinutes);
|
|
51
|
-
const dayCode = DAY_CODES[start
|
|
36
|
+
const dayCode = DAY_CODES[zonedWeekday(start, timeZone)] ?? "MO";
|
|
52
37
|
return `DTSTART:${dtStart}
|
|
53
38
|
DURATION:${duration}
|
|
54
39
|
RRULE:FREQ=WEEKLY;BYDAY=${dayCode}`;
|
|
@@ -135,6 +120,7 @@ const replaceWeeklyAvailabilityCommand = {
|
|
|
135
120
|
},
|
|
136
121
|
async execute(input, ctx) {
|
|
137
122
|
const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input);
|
|
123
|
+
warnOnUnresolvableTimeZone(parsed.timezone, "planner.availability.weekly.replace");
|
|
138
124
|
ensureTenantScope(ctx, parsed.tenantId);
|
|
139
125
|
ensureOrganizationScope(ctx, parsed.organizationId);
|
|
140
126
|
const em = ctx.container.resolve("em").fork();
|
|
@@ -182,10 +168,10 @@ const replaceWeeklyAvailabilityCommand = {
|
|
|
182
168
|
trx.persist(toDelete);
|
|
183
169
|
}
|
|
184
170
|
parsed.windows.forEach((window) => {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const rrule = buildWeeklyRrule(start, end);
|
|
171
|
+
const anchor = zonedWeeklyAnchor(window.weekday, window.start, window.end, parsed.timezone, now);
|
|
172
|
+
if (!anchor || anchor.start >= anchor.end) return;
|
|
173
|
+
const { start, end } = anchor;
|
|
174
|
+
const rrule = buildWeeklyRrule(start, end, parsed.timezone);
|
|
189
175
|
const record = trx.create(PlannerAvailabilityRule, {
|
|
190
176
|
tenantId: parsed.tenantId,
|
|
191
177
|
organizationId: parsed.organizationId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/planner/commands/availability-weekly.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'\nimport { PlannerAvailabilityRule, PlannerAvailabilityRuleSet } from '../data/entities'\nimport {\n plannerAvailabilityWeeklyReplaceSchema,\n type PlannerAvailabilityWeeklyReplaceInput,\n} from '../data/validators'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport {\n enforceCommandOptimisticLock,\n enforceRecordGoneIsConflict,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'\nimport type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'\nimport { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'\nimport { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'\nimport { E } from '#generated/entities.ids.generated'\n\nconst AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'\nconst AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND = 'planner.availability-rule-set'\n\nconst availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {\n entityType: E.planner.planner_availability_rule_set,\n}\n\n// Canonical resource kind for the parent rule set, matching the tag the CRUD\n// factory derives for `planner.availability-rule-sets.*` commands. Weekly\n// replace mutates the rule set's child `availability_rules`, so the parent is\n// the optimistic-lock consistency boundary (document-aggregate pattern).\nconst AVAILABILITY_RULE_SET_RESOURCE_KIND = 'planner.availability.rule.set'\n\nconst DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']\n\ntype AvailabilityRuleSnapshot = {\n id: string\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n timezone: string\n rrule: string\n exdates: string[]\n kind: PlannerAvailabilityKind\n note: string | null\n deletedAt: Date | null\n}\n\ntype WeeklyUndoPayload = {\n before: AvailabilityRuleSnapshot[]\n after: AvailabilityRuleSnapshot[]\n}\n\nfunction parseTimeInput(value: string): { hours: number; minutes: number } | null {\n const [hours, minutes] = 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\nfunction toDateForWeekday(weekday: number, time: string): Date | null {\n const parsed = parseTimeInput(time)\n if (!parsed) return null\n const now = new Date()\n const base = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n const diff = (weekday - base.getDay() + 7) % 7\n const target = new Date(base.getTime() + diff * 24 * 60 * 60 * 1000)\n target.setHours(parsed.hours, parsed.minutes, 0, 0)\n return target\n}\n\nfunction formatDuration(minutes: number): string {\n const clamped = Math.max(1, minutes)\n const hours = Math.floor(clamped / 60)\n const mins = clamped % 60\n if (hours > 0 && mins > 0) return `PT${hours}H${mins}M`\n if (hours > 0) return `PT${hours}H`\n return `PT${mins}M`\n}\n\nfunction buildWeeklyRrule(start: Date, end: Date): string {\n const dtStart = start.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'\n const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))\n const duration = formatDuration(durationMinutes)\n const dayCode = DAY_CODES[start.getDay()] ?? 'MO'\n return `DTSTART:${dtStart}\\nDURATION:${duration}\\nRRULE:FREQ=WEEKLY;BYDAY=${dayCode}`\n}\n\nfunction toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): AvailabilityRuleSnapshot {\n return {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n subjectType: record.subjectType,\n subjectId: record.subjectId,\n timezone: record.timezone,\n rrule: record.rrule,\n exdates: [...(record.exdates ?? [])],\n kind: record.kind,\n note: record.note ?? null,\n deletedAt: record.deletedAt ?? null,\n }\n}\n\nfunction nextRuleSetUpdatedAt(current: Date | null | undefined, fallback: Date): Date {\n const currentMs = current instanceof Date ? current.getTime() : Number.NaN\n if (Number.isFinite(currentMs) && fallback.getTime() <= currentMs) {\n return new Date(currentMs + 1)\n }\n return fallback\n}\n\nasync function loadWeeklySnapshots(\n em: EntityManager,\n params: {\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n }\n): Promise<AvailabilityRuleSnapshot[]> {\n const existing = await em.find(PlannerAvailabilityRule, {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n subjectType: params.subjectType,\n subjectId: params.subjectId,\n deletedAt: null,\n })\n return existing\n .filter((rule) => {\n const repeat = parseAvailabilityRuleWindow(rule).repeat\n return repeat === 'weekly' || repeat === 'daily'\n })\n .map(toAvailabilityRuleSnapshot)\n}\n\nasync function restoreAvailabilityRuleFromSnapshot(em: EntityManager, snapshot: AvailabilityRuleSnapshot): Promise<void> {\n let record = await em.findOne(PlannerAvailabilityRule, { id: snapshot.id })\n if (!record) {\n record = em.create(PlannerAvailabilityRule, {\n id: snapshot.id,\n tenantId: snapshot.tenantId,\n organizationId: snapshot.organizationId,\n subjectType: snapshot.subjectType,\n subjectId: snapshot.subjectId,\n timezone: snapshot.timezone,\n rrule: snapshot.rrule,\n exdates: snapshot.exdates ?? [],\n kind: snapshot.kind ?? 'availability',\n note: snapshot.note ?? null,\n createdAt: new Date(),\n updatedAt: new Date(),\n deletedAt: snapshot.deletedAt ?? null,\n })\n em.persist(record)\n } else {\n record.subjectType = snapshot.subjectType\n record.subjectId = snapshot.subjectId\n record.timezone = snapshot.timezone\n record.rrule = snapshot.rrule\n record.exdates = snapshot.exdates ?? []\n record.kind = snapshot.kind ?? 'availability'\n record.note = snapshot.note ?? null\n record.deletedAt = snapshot.deletedAt ?? null\n }\n}\n\nconst replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeeklyReplaceInput, { ok: true }> = {\n id: 'planner.availability.weekly.replace',\n async prepare(input, ctx) {\n const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const em = (ctx.container.resolve('em') as EntityManager)\n const before = await loadWeeklySnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n })\n return { before }\n },\n async execute(input, ctx) {\n const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const now = new Date()\n\n // The weekly rules of a rule set are a sub-resource of that rule set: the\n // parent is the optimistic-lock consistency boundary. Guard the parent's\n // version (so a stale weekly save loses to a concurrent rule-set\n // change/delete) and bump its `updated_at` after the replace (so a\n // concurrent rule-set delete/update with a stale token conflicts). See #2927.\n const touchedRuleSet = await em.transactional(async (trx): Promise<PlannerAvailabilityRuleSet | null> => {\n let ruleSet: PlannerAvailabilityRuleSet | null = null\n if (parsed.subjectType === 'ruleset') {\n ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {\n id: parsed.subjectId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (ruleSet) {\n enforceCommandOptimisticLock({\n resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,\n resourceId: ruleSet.id,\n current: ruleSet.updatedAt,\n request: ctx.request ?? null,\n })\n } else {\n // The rule set was deleted concurrently. When the client opted into\n // optimistic locking, surface the unified conflict instead of\n // silently writing orphan rules; otherwise preserve legacy behavior.\n enforceRecordGoneIsConflict({\n resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,\n resourceId: parsed.subjectId,\n request: ctx.request ?? null,\n })\n }\n }\n\n const existing = await trx.find(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n deletedAt: null,\n })\n\n const toDelete = existing.filter((rule) => {\n const repeat = parseAvailabilityRuleWindow(rule).repeat\n return repeat === 'weekly' || repeat === 'daily'\n })\n\n toDelete.forEach((rule) => {\n rule.deletedAt = now\n rule.updatedAt = now\n })\n\n if (toDelete.length) {\n trx.persist(toDelete)\n }\n\n parsed.windows.forEach((window) => {\n const start = toDateForWeekday(window.weekday, window.start)\n const end = toDateForWeekday(window.weekday, window.end)\n if (!start || !end || start >= end) return\n const rrule = buildWeeklyRrule(start, end)\n const record = trx.create(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n timezone: parsed.timezone,\n rrule,\n exdates: [],\n kind: 'availability',\n note: null,\n createdAt: now,\n updatedAt: now,\n })\n trx.persist(record)\n })\n\n if (ruleSet) {\n ruleSet.updatedAt = nextRuleSetUpdatedAt(ruleSet.updatedAt, now)\n trx.persist(ruleSet)\n }\n\n await trx.flush()\n return ruleSet\n })\n\n if (touchedRuleSet) {\n const dataEngine = ctx.container.resolve('dataEngine') as DataEngine\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: touchedRuleSet,\n identifiers: {\n id: touchedRuleSet.id,\n organizationId: touchedRuleSet.organizationId,\n tenantId: touchedRuleSet.tenantId,\n },\n events: plannerAvailabilityRuleSetCrudEvents,\n indexer: availabilityRuleSetCrudIndexer,\n })\n }\n\n return { ok: true }\n },\n buildLog: async ({ input, snapshots, ctx }) => {\n const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const after = await loadWeeklySnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n })\n const before = (snapshots.before as AvailabilityRuleSnapshot[] | undefined) ?? []\n const { translate } = await resolveTranslations()\n return {\n actionLabel: translate('planner.audit.availability.weekly.replace', 'Replace weekly availability'),\n resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,\n resourceId: null,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n snapshotBefore: before,\n snapshotAfter: after,\n payload: {\n undo: {\n before,\n after,\n } satisfies WeeklyUndoPayload,\n },\n context: parsed.subjectType === 'ruleset'\n ? { cacheAliases: [AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND] }\n : null,\n }\n },\n undo: async ({ logEntry, ctx }) => {\n const payload = extractUndoPayload<WeeklyUndoPayload>(logEntry)\n const before = payload?.before ?? []\n const after = payload?.after ?? []\n if (!before.length && !after.length) return\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n await em.transactional(async (trx) => {\n if (after.length) {\n const ids = after.map((rule) => rule.id)\n const records = await trx.find(PlannerAvailabilityRule, { id: { $in: ids } })\n records.forEach((record) => {\n record.deletedAt = new Date()\n })\n if (records.length) trx.persist(records)\n }\n\n for (const snapshot of before) {\n await restoreAvailabilityRuleFromSnapshot(trx, { ...snapshot, deletedAt: null })\n }\n\n await trx.flush()\n })\n },\n}\n\nregisterCommand(replaceWeeklyAvailabilityCommand)\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,uBAAuB;AAEhC,SAAS,mCAAmC;AAC5C,SAAS,yBAAyB,kCAAkC;AACpE;AAAA,EACE;AAAA,OAEK;AACP,SAAS,2BAA2B;AACpC,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAIP,SAAS,yBAAyB,mBAAmB,0BAA0B;AAC/E,SAAS,4CAA4C;AACrD,SAAS,SAAS;AAElB,MAAM,kCAAkC;AACxC,MAAM,4CAA4C;AAElD,MAAM,iCAAgF;AAAA,EACpF,YAAY,EAAE,QAAQ;AACxB;AAMA,MAAM,sCAAsC;AAE5C,MAAM,YAAY,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAqB3D,SAAS,eAAe,
|
|
4
|
+
"sourcesContent": ["import type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'\nimport { warnOnUnresolvableTimeZone, zonedWeeklyAnchor, zonedWeekday } from '@open-mercato/core/modules/planner/lib/availabilityTimezone'\nimport { PlannerAvailabilityRule, PlannerAvailabilityRuleSet } from '../data/entities'\nimport {\n plannerAvailabilityWeeklyReplaceSchema,\n type PlannerAvailabilityWeeklyReplaceInput,\n} from '../data/validators'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport {\n enforceCommandOptimisticLock,\n enforceRecordGoneIsConflict,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'\nimport type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'\nimport { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'\nimport { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'\nimport { E } from '#generated/entities.ids.generated'\n\nconst AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'\nconst AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND = 'planner.availability-rule-set'\n\nconst availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {\n entityType: E.planner.planner_availability_rule_set,\n}\n\n// Canonical resource kind for the parent rule set, matching the tag the CRUD\n// factory derives for `planner.availability-rule-sets.*` commands. Weekly\n// replace mutates the rule set's child `availability_rules`, so the parent is\n// the optimistic-lock consistency boundary (document-aggregate pattern).\nconst AVAILABILITY_RULE_SET_RESOURCE_KIND = 'planner.availability.rule.set'\n\nconst DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']\n\ntype AvailabilityRuleSnapshot = {\n id: string\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n timezone: string\n rrule: string\n exdates: string[]\n kind: PlannerAvailabilityKind\n note: string | null\n deletedAt: Date | null\n}\n\ntype WeeklyUndoPayload = {\n before: AvailabilityRuleSnapshot[]\n after: AvailabilityRuleSnapshot[]\n}\n\nfunction formatDuration(minutes: number): string {\n const clamped = Math.max(1, minutes)\n const hours = Math.floor(clamped / 60)\n const mins = clamped % 60\n if (hours > 0 && mins > 0) return `PT${hours}H${mins}M`\n if (hours > 0) return `PT${hours}H`\n return `PT${mins}M`\n}\n\nfunction buildWeeklyRrule(start: Date, end: Date, timeZone: string): string {\n const dtStart = start.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'\n const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))\n const duration = formatDuration(durationMinutes)\n // `BYDAY` names the weekday the window falls on in its DECLARED zone, which\n // is the meaningful label for a recurring local-time window. It is therefore\n // deliberately not consistent with the `Z`-suffixed `DTSTART` beside it: a\n // Pacific/Auckland Monday 09:00 ships DTSTART:...T210000Z, a Sunday in UTC,\n // with BYDAY=MO. Nothing in the planner reads BYDAY today (neither the\n // expander nor `parseAvailabilityRuleWindow`); a future iCal export must\n // re-derive it from the zone rather than trust it against DTSTART.\n const dayCode = DAY_CODES[zonedWeekday(start, timeZone)] ?? 'MO'\n return `DTSTART:${dtStart}\\nDURATION:${duration}\\nRRULE:FREQ=WEEKLY;BYDAY=${dayCode}`\n}\n\nfunction toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): AvailabilityRuleSnapshot {\n return {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n subjectType: record.subjectType,\n subjectId: record.subjectId,\n timezone: record.timezone,\n rrule: record.rrule,\n exdates: [...(record.exdates ?? [])],\n kind: record.kind,\n note: record.note ?? null,\n deletedAt: record.deletedAt ?? null,\n }\n}\n\nfunction nextRuleSetUpdatedAt(current: Date | null | undefined, fallback: Date): Date {\n const currentMs = current instanceof Date ? current.getTime() : Number.NaN\n if (Number.isFinite(currentMs) && fallback.getTime() <= currentMs) {\n return new Date(currentMs + 1)\n }\n return fallback\n}\n\nasync function loadWeeklySnapshots(\n em: EntityManager,\n params: {\n tenantId: string\n organizationId: string\n subjectType: PlannerAvailabilitySubjectType\n subjectId: string\n }\n): Promise<AvailabilityRuleSnapshot[]> {\n const existing = await em.find(PlannerAvailabilityRule, {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n subjectType: params.subjectType,\n subjectId: params.subjectId,\n deletedAt: null,\n })\n return existing\n .filter((rule) => {\n const repeat = parseAvailabilityRuleWindow(rule).repeat\n return repeat === 'weekly' || repeat === 'daily'\n })\n .map(toAvailabilityRuleSnapshot)\n}\n\nasync function restoreAvailabilityRuleFromSnapshot(em: EntityManager, snapshot: AvailabilityRuleSnapshot): Promise<void> {\n let record = await em.findOne(PlannerAvailabilityRule, { id: snapshot.id })\n if (!record) {\n record = em.create(PlannerAvailabilityRule, {\n id: snapshot.id,\n tenantId: snapshot.tenantId,\n organizationId: snapshot.organizationId,\n subjectType: snapshot.subjectType,\n subjectId: snapshot.subjectId,\n timezone: snapshot.timezone,\n rrule: snapshot.rrule,\n exdates: snapshot.exdates ?? [],\n kind: snapshot.kind ?? 'availability',\n note: snapshot.note ?? null,\n createdAt: new Date(),\n updatedAt: new Date(),\n deletedAt: snapshot.deletedAt ?? null,\n })\n em.persist(record)\n } else {\n record.subjectType = snapshot.subjectType\n record.subjectId = snapshot.subjectId\n record.timezone = snapshot.timezone\n record.rrule = snapshot.rrule\n record.exdates = snapshot.exdates ?? []\n record.kind = snapshot.kind ?? 'availability'\n record.note = snapshot.note ?? null\n record.deletedAt = snapshot.deletedAt ?? null\n }\n}\n\nconst replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeeklyReplaceInput, { ok: true }> = {\n id: 'planner.availability.weekly.replace',\n async prepare(input, ctx) {\n const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const em = (ctx.container.resolve('em') as EntityManager)\n const before = await loadWeeklySnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n })\n return { before }\n },\n async execute(input, ctx) {\n const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)\n warnOnUnresolvableTimeZone(parsed.timezone, 'planner.availability.weekly.replace')\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const now = new Date()\n\n // The weekly rules of a rule set are a sub-resource of that rule set: the\n // parent is the optimistic-lock consistency boundary. Guard the parent's\n // version (so a stale weekly save loses to a concurrent rule-set\n // change/delete) and bump its `updated_at` after the replace (so a\n // concurrent rule-set delete/update with a stale token conflicts). See #2927.\n const touchedRuleSet = await em.transactional(async (trx): Promise<PlannerAvailabilityRuleSet | null> => {\n let ruleSet: PlannerAvailabilityRuleSet | null = null\n if (parsed.subjectType === 'ruleset') {\n ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {\n id: parsed.subjectId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (ruleSet) {\n enforceCommandOptimisticLock({\n resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,\n resourceId: ruleSet.id,\n current: ruleSet.updatedAt,\n request: ctx.request ?? null,\n })\n } else {\n // The rule set was deleted concurrently. When the client opted into\n // optimistic locking, surface the unified conflict instead of\n // silently writing orphan rules; otherwise preserve legacy behavior.\n enforceRecordGoneIsConflict({\n resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,\n resourceId: parsed.subjectId,\n request: ctx.request ?? null,\n })\n }\n }\n\n const existing = await trx.find(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n deletedAt: null,\n })\n\n const toDelete = existing.filter((rule) => {\n const repeat = parseAvailabilityRuleWindow(rule).repeat\n return repeat === 'weekly' || repeat === 'daily'\n })\n\n toDelete.forEach((rule) => {\n rule.deletedAt = now\n rule.updatedAt = now\n })\n\n if (toDelete.length) {\n trx.persist(toDelete)\n }\n\n parsed.windows.forEach((window) => {\n const anchor = zonedWeeklyAnchor(window.weekday, window.start, window.end, parsed.timezone, now)\n if (!anchor || anchor.start >= anchor.end) return\n const { start, end } = anchor\n const rrule = buildWeeklyRrule(start, end, parsed.timezone)\n const record = trx.create(PlannerAvailabilityRule, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n timezone: parsed.timezone,\n rrule,\n exdates: [],\n kind: 'availability',\n note: null,\n createdAt: now,\n updatedAt: now,\n })\n trx.persist(record)\n })\n\n if (ruleSet) {\n ruleSet.updatedAt = nextRuleSetUpdatedAt(ruleSet.updatedAt, now)\n trx.persist(ruleSet)\n }\n\n await trx.flush()\n return ruleSet\n })\n\n if (touchedRuleSet) {\n const dataEngine = ctx.container.resolve('dataEngine') as DataEngine\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: touchedRuleSet,\n identifiers: {\n id: touchedRuleSet.id,\n organizationId: touchedRuleSet.organizationId,\n tenantId: touchedRuleSet.tenantId,\n },\n events: plannerAvailabilityRuleSetCrudEvents,\n indexer: availabilityRuleSetCrudIndexer,\n })\n }\n\n return { ok: true }\n },\n buildLog: async ({ input, snapshots, ctx }) => {\n const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const after = await loadWeeklySnapshots(em, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n subjectType: parsed.subjectType,\n subjectId: parsed.subjectId,\n })\n const before = (snapshots.before as AvailabilityRuleSnapshot[] | undefined) ?? []\n const { translate } = await resolveTranslations()\n return {\n actionLabel: translate('planner.audit.availability.weekly.replace', 'Replace weekly availability'),\n resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,\n resourceId: null,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n snapshotBefore: before,\n snapshotAfter: after,\n payload: {\n undo: {\n before,\n after,\n } satisfies WeeklyUndoPayload,\n },\n context: parsed.subjectType === 'ruleset'\n ? { cacheAliases: [AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND] }\n : null,\n }\n },\n undo: async ({ logEntry, ctx }) => {\n const payload = extractUndoPayload<WeeklyUndoPayload>(logEntry)\n const before = payload?.before ?? []\n const after = payload?.after ?? []\n if (!before.length && !after.length) return\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n await em.transactional(async (trx) => {\n if (after.length) {\n const ids = after.map((rule) => rule.id)\n const records = await trx.find(PlannerAvailabilityRule, { id: { $in: ids } })\n records.forEach((record) => {\n record.deletedAt = new Date()\n })\n if (records.length) trx.persist(records)\n }\n\n for (const snapshot of before) {\n await restoreAvailabilityRuleFromSnapshot(trx, { ...snapshot, deletedAt: null })\n }\n\n await trx.flush()\n })\n },\n}\n\nregisterCommand(replaceWeeklyAvailabilityCommand)\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveTimeZone, zonedWallDateToInstant } from "./availabilityTimezone.js";
|
|
1
2
|
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2
3
|
const timezoneFormatters = /* @__PURE__ */ new Map();
|
|
3
4
|
function zonedParts(instant, timezone) {
|
|
@@ -20,20 +21,8 @@ function zonedParts(instant, timezone) {
|
|
|
20
21
|
const number = (kind) => Number(parts.find((part) => part.type === kind)?.value);
|
|
21
22
|
return { year: number("year"), month: number("month"), day: number("day"), hour: number("hour"), minute: number("minute"), second: number("second") };
|
|
22
23
|
}
|
|
23
|
-
function zonedWallToInstant(wall, timezone) {
|
|
24
|
-
const target = wall.getTime();
|
|
25
|
-
let candidate = target;
|
|
26
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
27
|
-
const parts = zonedParts(new Date(candidate), timezone);
|
|
28
|
-
const displayed = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
29
|
-
const next = candidate + target - displayed;
|
|
30
|
-
if (next === candidate) return new Date(candidate);
|
|
31
|
-
candidate = next;
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
24
|
function zonedStartOfDay(dayKey, timezone) {
|
|
36
|
-
return
|
|
25
|
+
return zonedWallDateToInstant(/* @__PURE__ */ new Date(`${dayKey}T00:00:00Z`), timezone);
|
|
37
26
|
}
|
|
38
27
|
function dayKeyForRule(instant, timezone) {
|
|
39
28
|
if (!timezone || timezone === "UTC" || timezone === "Etc/UTC" || timezone === "Etc/GMT") return toDayKey(instant);
|
|
@@ -106,10 +95,10 @@ function expandZonedRule(rule, parsed, range, weeklyScheduleTemplate) {
|
|
|
106
95
|
let wall = new Date(anchorWall + skipped * step);
|
|
107
96
|
let remaining = (parsed.count ?? Number.POSITIVE_INFINITY) - skipped;
|
|
108
97
|
while (wall.getTime() <= range.end.getTime() + DAY_MS && remaining > 0) {
|
|
109
|
-
const start =
|
|
110
|
-
if (
|
|
111
|
-
const end = new Date(
|
|
112
|
-
if (end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id });
|
|
98
|
+
const start = zonedWallDateToInstant(wall, timezone);
|
|
99
|
+
if (!shouldExcludeOccurrence(start, rule.exdates, timezone)) {
|
|
100
|
+
const end = zonedWallDateToInstant(new Date(wall.getTime() + duration), timezone);
|
|
101
|
+
if (end > start && end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id });
|
|
113
102
|
}
|
|
114
103
|
wall = new Date(wall.getTime() + step);
|
|
115
104
|
remaining -= 1;
|
|
@@ -181,7 +170,10 @@ function subtractWindow(window, blockers) {
|
|
|
181
170
|
return segments;
|
|
182
171
|
}
|
|
183
172
|
function getMergedAvailabilityWindows(params) {
|
|
184
|
-
const rules = params.respectTimezone ? params.rules
|
|
173
|
+
const rules = params.respectTimezone ? params.rules.map((rule) => ({
|
|
174
|
+
...rule,
|
|
175
|
+
timezone: rule.timezone ? resolveTimeZone(rule.timezone) : rule.timezone
|
|
176
|
+
})) : params.rules.map((rule) => ({ ...rule, timezone: void 0 }));
|
|
185
177
|
const parsedRules = rules.map((rule) => {
|
|
186
178
|
const parsed = parseRrule(rule.rrule);
|
|
187
179
|
if (!parsed) return null;
|