{"record":{"id":"f9ef4d139d16014d","repo":"calcom/cal.diy","slug":"either-expiresat-or-maxusagecount-must-be-provided","errorCode":null,"errorMessage":"Either expiresAt or maxUsageCount must be provided","messagePattern":"Either expiresAt or maxUsageCount must be provided","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/api/v2/src/platform/event-types-private-links/services/private-links-input.service.ts","lineNumber":14,"sourceCode":"import { Injectable } from \"@nestjs/common\";\n\nimport { CreatePrivateLinkInput, UpdatePrivateLinkInput } from \"@calcom/platform-types\";\n\n@Injectable()\nexport class PrivateLinksInputService {\n  constructor() {}\n\n  transformCreateInput(input: CreatePrivateLinkInput): CreatePrivateLinkInput {\n    const hasExpires = input.expiresAt !== undefined && input.expiresAt !== null;\n    const hasMaxCount = typeof input.maxUsageCount === \"number\";\n\n    if (!hasExpires && !hasMaxCount) {\n      throw new Error(\"Either expiresAt or maxUsageCount must be provided\");\n    }\n\n    if (hasExpires && hasMaxCount) {\n      throw new Error(\"Provide only one of expiresAt or maxUsageCount\");\n    }\n\n    return {\n      expiresAt: input.expiresAt,\n      maxUsageCount: input.maxUsageCount ?? (hasMaxCount ? input.maxUsageCount : undefined),\n    };\n  }\n\n  transformUpdateInput(input: UpdatePrivateLinkInput): UpdatePrivateLinkInput {\n    return {\n      linkId: input.linkId,\n      expiresAt: input.expiresAt,\n      maxUsageCount: input.maxUsageCount,\n    };","sourceCodeStart":1,"sourceCodeEnd":32,"githubUrl":"https://github.com/calcom/cal.diy/blob/176037d0afbe572f870a3c702985e7cd83fe6c0c/apps/api/v2/src/platform/event-types-private-links/services/private-links-input.service.ts#L1-L32","documentation":"Thrown by PrivateLinksInputService.transformCreateInput. A private link must be constrained by either a time bound (expiresAt) or a usage bound (maxUsageCount): exactly one of the two. If neither is present in the CreatePrivateLinkInput, a plain Error('Either expiresAt or maxUsageCount must be provided') is thrown; this then surfaces through the service's catch block as a 400. The guard enforces that no private link can be both unbounded and eternal.","triggerScenarios":"POST /v2/event-types/{id}/private-links with a body like {} or {expiresAt: null, maxUsageCount: undefined}; a client that constructs the input from optional form fields where both were left blank; maxUsageCount passed as a string (typeof !== 'number' so hasMaxCount is false) alongside a null expiresAt.","commonSituations":"Frontend 'create private link' form submitted with both fields empty; maxUsageCount sent as a string '5' rather than the number 5 (typeof '5' === 'string', so the guard doesn't see it); a default value was removed during a refactor leaving both undefined.","solutions":["Send exactly one of { expiresAt: <iso8601|null> } or { maxUsageCount: <number> } in the request body.","If you want a usage cap, ensure maxUsageCount is a JSON number, not a string.","On the client, disable submit until one of the two fields is filled."],"exampleFix":"// before\nawait api.post(`/v2/event-types/${id}/private-links`, {});\n\n// after\nawait api.post(`/v2/event-types/${id}/private-links`, { maxUsageCount: 5 });\n// or\nawait api.post(`/v2/event-types/${id}/private-links`, { expiresAt: '2025-12-31T23:59:59Z' });","handlingStrategy":"validation","validationCode":"function validateCreateInput(input: { expiresAt?: string | null; maxUsageCount?: number }) {\n  const hasExpires = input.expiresAt != null;\n  const hasMax = typeof input.maxUsageCount === 'number';\n  if (!hasExpires && !hasMax) throw new Error('Provide expiresAt or maxUsageCount');\n  if (hasExpires && hasMax) throw new Error('Provide only one');\n  return input;\n}","typeGuard":"function isValidCreateInput(i: unknown): i is { expiresAt?: string } | { maxUsageCount: number } {\n  if (typeof i !== 'object' || i === null) return false;\n  const o = i as Record<string, unknown>;\n  const hasExpires = o.expiresAt != null;\n  const hasMax = typeof o.maxUsageCount === 'number';\n  return (hasExpires && !hasMax) || (!hasExpires && hasMax);\n}","tryCatchPattern":"try {\n  await privateLinksService.createPrivateLink(eventTypeId, userId, input);\n} catch (e) {\n  if (e instanceof BadRequestException && /expiresAt|maxUsageCount/.test(e.message)) {\n    // fix the payload: send exactly one of the two\n  } else throw e;\n}","preventionTips":["Make the two fields mutually exclusive in the UI (radio group).","Send maxUsageCount as a JSON number, not a string.","Disable submit until exactly one field is set."],"tags":["private-links","validation","input","api-v2"],"backgroundTag":null,"analyzedSha":"176037d0afbe572f870a3c702985e7cd83fe6c0c","analyzedAt":"2026-08-12T19:12:41.464Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}