Files
calendar/packages/features/hashedLink/lib/service/HashedLinkService.ts
T
98b6d63164 refactor: apply biome formatting to packages/features (#27844)
* refactor: apply biome formatting to packages/features (batch 1 - small subdirs)

Format small subdirectories in packages/features: di, flags, holidays, oauth,
settings, users, assignment-reason, selectedCalendar, hashedLink, host, form,
form-builder, availability, data-table, pbac, schedules, troubleshooter,
eventtypes, calendar-subscription, and root-level files.

Also includes straggler apps/web BookEventForm.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 2 - medium subdirs)

Format medium subdirectories in packages/features: auth, credentials,
calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone,
tasker, and webhooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 3 - bookings + insights)

Format bookings and insights subdirectories in packages/features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 4 - ee)

Format packages/features/ee subdirectory covering billing, workflows,
organizations, teams, managed-event-types, round-robin, dsync,
integration-attribute-sync, and payments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1)

Format booking-audit di, actions, common, dto, repository, and types
subdirectories in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2)

Format booking-audit service subdirectory in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:47:14 +01:00

190 lines
6.5 KiB
TypeScript

import { MembershipService } from "@calcom/features/membership/services/membershipService";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { validateHashedLinkData } from "@calcom/lib/hashedLinksUtils";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { HashedLinkRepository } from "../repository/HashedLinkRepository";
import { type HashedLinkInputType } from "../repository/HashedLinkRepository";
type NormalizedLink = {
link: string;
expiresAt: Date | null;
maxUsageCount?: number | null;
};
interface HashedLinkServiceDeps {
hashedLinkRepository: HashedLinkRepository;
membershipService: MembershipService;
}
export class HashedLinkService {
private readonly hashedLinkRepository: HashedLinkRepository;
private readonly membershipService: MembershipService;
constructor(deps?: HashedLinkServiceDeps) {
this.hashedLinkRepository = deps?.hashedLinkRepository ?? HashedLinkRepository.create();
this.membershipService = deps?.membershipService ?? new MembershipService();
}
/**
* Normalizes link input to a consistent format
* @param input - String link or object with link and options
* @returns Normalized link object
*/
private normalizeLinkInput(input: string | HashedLinkInputType): NormalizedLink {
return typeof input === "string"
? { link: input, expiresAt: null }
: {
link: input.link,
expiresAt: input.expiresAt ?? null,
maxUsageCount: input.maxUsageCount,
};
}
/**
* Handles multiple private links operations - orchestrates create, update, and delete
* @param eventTypeId - The ID of the event type
* @param multiplePrivateLinks - Array of links to create/update (can be strings or objects with expiration)
* @param connectedMultiplePrivateLinks - Array of existing link IDs for this event type
*/
async handleMultiplePrivateLinks({
eventTypeId,
multiplePrivateLinks,
connectedMultiplePrivateLinks,
}: {
eventTypeId: number;
multiplePrivateLinks?: (string | HashedLinkInputType)[];
connectedMultiplePrivateLinks: string[];
}) {
if (!eventTypeId || eventTypeId <= 0) {
throw new Error("Invalid event type ID");
}
if (!multiplePrivateLinks || multiplePrivateLinks.length === 0) {
await this.hashedLinkRepository.deleteLinks(eventTypeId, connectedMultiplePrivateLinks);
return;
}
const normalizedLinks = multiplePrivateLinks.map((input) => this.normalizeLinkInput(input));
const currentLinks = normalizedLinks.map((l) => l.link);
const currentLinksSet = new Set(currentLinks);
const linksToDelete = connectedMultiplePrivateLinks.filter((link) => !currentLinksSet.has(link));
await this.hashedLinkRepository.deleteLinks(eventTypeId, linksToDelete);
const existingLinksSet = new Set(connectedMultiplePrivateLinks);
for (const linkData of normalizedLinks) {
const exists = existingLinksSet.has(linkData.link);
if (!exists) {
await this.hashedLinkRepository.createLink(eventTypeId, linkData);
} else {
await this.hashedLinkRepository.updateLink(eventTypeId, linkData);
}
}
}
/**
* Validates a link without incrementing usage count
* Handles both time-based and usage-based expiration with timezone awareness
* @param linkId - The hashed link ID to validate
* @returns The validated link data
* @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid
*/
async validate(linkId: string) {
if (!linkId || typeof linkId !== "string") {
throw new Error("Invalid link ID");
}
const hashedLink = await this.hashedLinkRepository.findLinkWithValidationData(linkId);
if (!hashedLink) {
throw new Error(ErrorCode.PrivateLinkExpired);
}
validateHashedLinkData(hashedLink);
return hashedLink;
}
/**
* Validates a link and increments usage count if valid
* Handles both time-based and usage-based expiration with timezone awareness
* @param linkId - The hashed link ID to validate
* @returns The validated link data
* @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid
*/
async validateAndIncrementUsage(linkId: string) {
const hashedLink = await this.validate(linkId);
if (hashedLink.expiresAt) return hashedLink; // Time-based links don't need usage count increment
if (hashedLink.maxUsageCount && hashedLink.maxUsageCount > 0) {
try {
await this.hashedLinkRepository.incrementUsage(hashedLink.id, hashedLink.maxUsageCount);
} catch (e) {
logger.error("Error incrementing usage for hashed link", safeStringify(e));
throw new Error(ErrorCode.PrivateLinkExpired);
}
}
return hashedLink;
}
async findLinkWithDetails(linkId: string) {
const hashedLink = await this.hashedLinkRepository.findLinkWithDetails(linkId);
if (!hashedLink) {
return null;
}
return hashedLink;
}
async createLinkForEventType(eventTypeId: number, input: string | HashedLinkInputType) {
const normalized = this.normalizeLinkInput(input);
return this.hashedLinkRepository.createLink(eventTypeId, normalized);
}
async updateLinkForEventType(eventTypeId: number, input: string | HashedLinkInputType) {
const normalized = this.normalizeLinkInput(input);
return this.hashedLinkRepository.updateLink(eventTypeId, normalized);
}
async deleteLinkForEventType(eventTypeId: number, linkId: string) {
return this.hashedLinkRepository.deleteLinks(eventTypeId, [linkId]);
}
async listLinksByEventType(eventTypeId: number) {
return this.hashedLinkRepository.findLinksByEventTypeId(eventTypeId);
}
/**
* Checks if a user has permission to access a link
* @param link - Link object with event type information
* @param userId - The user ID to check permissions for
* @returns true if user has permission, false otherwise
*/
async checkUserPermissionForLink(
link: { eventType: { teamId?: number | null; userId?: number | null } },
userId: number
): Promise<boolean> {
if (!link?.eventType || !userId || userId <= 0) {
return false;
}
// If it's a user event type, check if user owns it
if (link.eventType.userId) {
return link.eventType.userId === userId;
}
//No user ownership and no team - deny access
if (!link.eventType.teamId) {
return false;
}
const membership = await this.membershipService.checkMembership(link.eventType.teamId, userId);
return membership.isMember;
}
}