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,
};
}
Source