Source

services/url.service.js

import { Investigation } from "@models/investigation/investigation.model";
import Url from "@models/urls/url.model";
import { AUTOGEN_RANDOM_BYTES, MAX_UNIQUE_RETRIES, SUBLINK_MAX_LENGTH, } from "@typez/urls/constants";
import { UrlOrigin } from "@typez/urls/enums";
import { ApiError } from "@utils/apiError";
import { getLogger } from "@utils/asyncLocalStorage";
import { randomBytes } from "node:crypto";
/**
 * Slugify a string for use as subLink: lowercase, alphanumeric + . _ -
 * @param {string} text - Raw string (e.g. investigation title)
 * @returns {string} Slug suitable for subLink (max SUBLINK_MAX_LENGTH chars)
 */
function slugify(text) {
    return (text
        .trim()
        .toLowerCase()
        .replace(/\s+/g, "-")
        .replace(/[^a-z0-9._-]/g, "")
        .slice(0, SUBLINK_MAX_LENGTH) || "");
}
/**
 * Generate a short random subLink suffix (URL-safe).
 * @returns {string} Lowercase URL-safe string (up to 8 chars)
 */
function randomSuffix() {
    return randomBytes(AUTOGEN_RANDOM_BYTES).toString("base64url").toLowerCase().slice(0, 8);
}
/**
 * Check if a (key, subLink) pair already exists in the URLs collection.
 * @param {string} key - Link key (e.g. "physical-tool")
 * @param {string} subLink - Slug (normalized to lowercase)
 * @returns {Promise<boolean>} True if a URL document exists with this key and subLink
 */
async function existsByKeyAndSubLink(key, subLink) {
    const normalized = subLink.trim().toLowerCase();
    const found = await Url.findOne({ key, subLink: normalized }).lean();
    return !!found;
}
/**
 * Create a new link for an investigation.
 * If subLink is provided: validate uniqueness and set origin HUMAN.
 * If subLink is omitted: autogenerate from investigation title (or random), set origin AUTO.
 * @param {Types.ObjectId} investigationId - Investigation to attach the link to
 * @param {object} options - Link options
 * @param {string} options.key - Link key (e.g. "physical-tool")
 * @param {string} [options.subLink] - Custom slug; if omitted, one is generated
 * @param {string} [options.description] - Optional description
 * @param {Types.ObjectId | null} createdBy - User ID who created the link (or null)
 * @returns {Promise<IUrl>} The saved URL document
 * @throws {ApiError} 404 if investigation not found
 * @throws {ApiError} 409 if subLink is provided and already exists for this key
 */
export async function createLink(investigationId, options, createdBy) {
    const logger = getLogger();
    const { key, subLink: providedSubLink, description = "" } = options;
    const investigation = await Investigation.findById(investigationId).lean();
    if (!investigation) {
        throw new ApiError(404, "Investigation not found");
    }
    let subLink;
    let origin;
    if (providedSubLink !== undefined && providedSubLink !== "") {
        const normalized = providedSubLink.trim().toLowerCase();
        const exists = await existsByKeyAndSubLink(key, normalized);
        if (exists) {
            throw new ApiError(409, "Link with this key and subLink already exists");
        }
        subLink = normalized;
        origin = UrlOrigin.HUMAN;
    }
    else {
        const title = investigation.title?.value ??
            investigation.title?.aiGeneratedValue ??
            "";
        let base = slugify(typeof title === "string" ? title : String(title));
        if (!base)
            base = "inv";
        let attempts = 0;
        do {
            subLink = attempts === 0 ? base : `${base}-${randomSuffix()}`;
            if (subLink.length > 100)
                subLink = subLink.slice(0, 100);
            const exists = await existsByKeyAndSubLink(key, subLink);
            if (!exists)
                break;
            attempts++;
        } while (attempts < MAX_UNIQUE_RETRIES);
        if (attempts >= MAX_UNIQUE_RETRIES) {
            subLink = `inv-${randomSuffix()}-${Date.now().toString(36)}`.slice(0, 100);
        }
        origin = UrlOrigin.AUTO;
    }
    const doc = new Url({
        investigationId,
        key,
        subLink,
        description,
        origin,
        counters: { visitsTotal: 0, completionsTotal: 0 },
        createdBy,
    });
    const saved = await doc.save();
    logger.info({ urlId: saved._id, investigationId: saved.investigationId, key, subLink, origin }, "Link created");
    return saved;
}
/**
 * List all links for an investigation, sorted by creation date.
 * @param {Types.ObjectId} investigationId - Investigation ID
 * @returns {Promise<IUrl[]>} Array of URL documents (plain objects from lean())
 */
export async function getLinksByInvestigationId(investigationId) {
    const list = await Url.find({ investigationId }).sort({ createdAt: 1 }).lean();
    return list;
}
/**
 * Resolve a link by (key, subLink): increment visit count and return investigation id.
 * @param {string} key - Link key (e.g. "physical-tool")
 * @param {string} subLink - Slug identifying the link
 * @returns {Promise<IResolveLinkResult>} Resolve result with investigationId, linkId, and counters
 * @throws {ApiError} 404 if no URL document exists for this key and subLink
 */
export async function resolveAndIncrementVisit(key, subLink) {
    const normalizedKey = key.trim();
    const normalizedSubLink = subLink.trim().toLowerCase();
    const updated = await Url.findOneAndUpdate({ key: normalizedKey, subLink: normalizedSubLink }, { $inc: { "counters.visitsTotal": 1 } }, { new: true }).lean();
    if (!updated) {
        throw new ApiError(404, "Link not found");
    }
    const counters = updated.counters ?? {
        visitsTotal: 0,
        completionsTotal: 0,
    };
    return {
        investigationId: String(updated.investigationId),
        linkId: String(updated._id),
        counters,
    };
}
/**
 * Map a URL document (or lean plain object) to the response DTO shape.
 * @param {IUrl} doc - URL document or lean result
 * @returns {IUrlResponseDto} DTO with string ids and flattened counters
 */
export function toUrlResponseDto(doc) {
    return {
        _id: doc._id.toString(),
        investigationId: doc.investigationId.toString(),
        key: doc.key,
        subLink: doc.subLink,
        description: doc.description ?? "",
        origin: doc.origin,
        counters: {
            visitsTotal: doc.counters?.visitsTotal ?? 0,
            completionsTotal: doc.counters?.completionsTotal ?? 0,
        },
        createdBy: doc.createdBy ? doc.createdBy.toString() : null,
        createdAt: doc.createdAt,
        updatedAt: doc.updatedAt,
    };
}