diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index 0e32710..3ce4226 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -1,8 +1,22 @@ -import { format } from "date-fns"; -import { redirect } from "next/navigation"; -import { DateFormatOptions, DateFormatPattern } from "./types/date-format.interface"; -import { toast } from "sonner"; -import { IUserSchema } from "@/src/entities/models/users/users.model"; +import { format } from 'date-fns'; +import { redirect } from 'next/navigation'; +import { + DateFormatOptions, + DateFormatPattern, +} from './types/date-format.interface'; +import { toast } from 'sonner'; +import { IUserSchema } from '@/src/entities/models/users/users.model'; +import db from '../../prisma/db'; +import { v4 as uuidv4 } from 'uuid'; +import * as crypto from 'crypto'; + +// Maintain a registry of used IDs to prevent duplicates +const usedIdRegistry = new Set(); + +// Type definition for the global counter +declare global { + var __idCounter: number; +} /** * Redirects to a specified path with an encoded message as a query parameter. @@ -12,9 +26,9 @@ import { IUserSchema } from "@/src/entities/models/users/users.model"; * @returns {never} This function doesn't return as it triggers a redirect. */ export function encodedRedirect( - type: "error" | "success", + type: 'error' | 'success', path: string, - message: string, + message: string ) { return redirect(`${path}?${type}=${encodeURIComponent(message)}`); } @@ -27,17 +41,17 @@ export function encodedRedirect( // Helper function to ensure URLs are properly formatted export function formatUrl(url: string): string { // If URL starts with a slash, it's already absolute - if (url.startsWith("/")) { + if (url.startsWith('/')) { return url; } // Otherwise, ensure it's properly formatted relative to root // Remove any potential duplicated '/dashboard' prefixes - if (url.startsWith("dashboard/")) { - return "/" + url; + if (url.startsWith('dashboard/')) { + return '/' + url; } - return "/" + url; + return '/' + url; } /** @@ -52,8 +66,7 @@ export function createFormData(): FormData { } }); return data; -}; - +} /** * Generates a unique username based on the provided email address. @@ -71,7 +84,7 @@ export function createFormData(): FormData { * ``` */ export function generateUsername(email: string): string { - const [localPart] = email.split("@"); + const [localPart] = email.split('@'); const randomSuffix = Math.random().toString(36).substring(2, 8); // Generate a random alphanumeric string return `${localPart}.${randomSuffix}`; } @@ -84,70 +97,70 @@ export function generateUsername(email: string): string { * @example * // Using default format * formatDate("2025-03-23") - * + * * // Using a custom format string * formatDate("2025-03-23", "yyyy-MM-dd") - * + * * // Using formatting options * formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" }) */ export const formatDate = ( date: string | Date | undefined | null, - options: DateFormatOptions | DateFormatPattern = { format: "PPpp" } + options: DateFormatOptions | DateFormatPattern = { format: 'PPpp' } ): string => { if (!date) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } const dateObj = date instanceof Date ? date : new Date(date); // Handle invalid dates if (isNaN(dateObj.getTime())) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } - if (typeof options === "string") { + if (typeof options === 'string') { return format(dateObj, options); } - const { format: formatPattern = "PPpp", locale } = options; + const { format: formatPattern = 'PPpp', locale } = options; return locale ? format(dateObj, formatPattern, { locale }) : format(dateObj, formatPattern); }; -export const copyItem = (item: string, options?: { - label?: string, - onSuccess?: () => void, - onError?: (error: unknown) => void -}) => { +export const copyItem = ( + item: string, + options?: { + label?: string; + onSuccess?: () => void; + onError?: (error: unknown) => void; + } +) => { if (!navigator.clipboard) { - const error = new Error("Clipboard not supported"); - toast.error("Clipboard not supported"); + const error = new Error('Clipboard not supported'); + toast.error('Clipboard not supported'); options?.onError?.(error); return; } if (!item) { - const error = new Error("Nothing to copy"); - toast.error("Nothing to copy"); + const error = new Error('Nothing to copy'); + toast.error('Nothing to copy'); options?.onError?.(error); return; } - navigator.clipboard.writeText(item) + navigator.clipboard + .writeText(item) .then(() => { const label = options?.label || item; toast.success(`${label} copied to clipboard`); options?.onSuccess?.(); }) .catch((error) => { - toast.error("Failed to copy to clipboard"); + toast.error('Failed to copy to clipboard'); options?.onError?.(error); }); }; @@ -160,67 +173,59 @@ export const copyItem = (item: string, options?: { * @example * // Using default format * formatDate("2025-03-23") - * + * * // Using a custom format string * formatDate("2025-03-23", "yyyy-MM-dd") - * + * * // Using formatting options * formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" }) */ export const formatDateWithFallback = ( date: string | Date | undefined | null, - options: DateFormatOptions | DateFormatPattern = { format: "PPpp" } + options: DateFormatOptions | DateFormatPattern = { format: 'PPpp' } ): string => { if (!date) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } const dateObj = date instanceof Date ? date : new Date(date); // Handle invalid dates if (isNaN(dateObj.getTime())) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } - if (typeof options === "string") { + if (typeof options === 'string') { return format(dateObj, options); } - const { format: formatPattern = "PPpp", locale } = options; + const { format: formatPattern = 'PPpp', locale } = options; return locale ? format(dateObj, formatPattern, { locale }) : format(dateObj, formatPattern); -} +}; export const formatDateWithLocale = ( date: string | Date | undefined | null, - options: DateFormatOptions | DateFormatPattern = { format: "PPpp" } + options: DateFormatOptions | DateFormatPattern = { format: 'PPpp' } ): string => { if (!date) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } const dateObj = date instanceof Date ? date : new Date(date); // Handle invalid dates if (isNaN(dateObj.getTime())) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } - if (typeof options === "string") { + if (typeof options === 'string') { return format(dateObj, options); } - const { format: formatPattern = "PPpp", locale } = options; + const { format: formatPattern = 'PPpp', locale } = options; return locale ? format(dateObj, formatPattern, { locale }) @@ -235,42 +240,38 @@ export const formatDateWithLocale = ( * @example * // Using default format * formatDate("2025-03-23") - * + * * // Using a custom format string * formatDate("2025-03-23", "yyyy-MM-dd") - * + * * // Using formatting options * formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" }) */ export const formatDateWithLocaleAndFallback = ( date: string | Date | undefined | null, - options: DateFormatOptions | DateFormatPattern = { format: "PPpp" } + options: DateFormatOptions | DateFormatPattern = { format: 'PPpp' } ): string => { if (!date) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } const dateObj = date instanceof Date ? date : new Date(date); // Handle invalid dates if (isNaN(dateObj.getTime())) { - return typeof options === "string" - ? "-" - : (options.fallback || "-"); + return typeof options === 'string' ? '-' : options.fallback || '-'; } - if (typeof options === "string") { + if (typeof options === 'string') { return format(dateObj, options); } - const { format: formatPattern = "PPpp", locale } = options; + const { format: formatPattern = 'PPpp', locale } = options; return locale ? format(dateObj, formatPattern, { locale }) : format(dateObj, formatPattern); -} +}; /** * Generates a full name from first and last names. @@ -278,9 +279,12 @@ export const formatDateWithLocaleAndFallback = ( * @param lastName - The last name. * @returns The full name or "User" if both names are empty. */ -export const getFullName = (firstName: string | null | undefined, lastName: string | null | undefined): string => { - return `${firstName || ""} ${lastName || ""}`.trim() || "User"; -} +export const getFullName = ( + firstName: string | null | undefined, + lastName: string | null | undefined +): string => { + return `${firstName || ''} ${lastName || ''}`.trim() || 'User'; +}; /** * Generates initials for a user based on their first and last names. @@ -289,7 +293,11 @@ export const getFullName = (firstName: string | null | undefined, lastName: stri * @param email - The email address. * @returns The initials or "U" if both names are empty. */ -export const getInitials = (firstName: string, lastName: string, email: string): string => { +export const getInitials = ( + firstName: string, + lastName: string, + email: string +): string => { if (firstName && lastName) { return `${firstName[0]}${lastName[0]}`.toUpperCase(); } @@ -299,9 +307,8 @@ export const getInitials = (firstName: string, lastName: string, email: string): if (email) { return email[0].toUpperCase(); } - return "U"; -} - + return 'U'; +}; export function calculateUserStats(users: IUserSchema[] | undefined) { if (!users || !Array.isArray(users)) { @@ -337,7 +344,10 @@ export function calculateUserStats(users: IUserSchema[] | undefined) { * @param params - Object containing query parameters * @returns Formatted route with query parameters */ -export const createRoute = (baseRoute: string, params?: Record): string => { +export const createRoute = ( + baseRoute: string, + params?: Record +): string => { if (!params || Object.keys(params).length === 0) { return baseRoute; } @@ -348,3 +358,293 @@ export const createRoute = (baseRoute: string, params?: Record): return `${baseRoute}?${queryString}`; }; + +/** + * Universal Custom ID Generator + * Creates structured, readable IDs for any system or entity + * + * @param {Object} options - Configuration options + * @param {string} options.prefix - Primary identifier prefix (e.g., "CRIME", "USER", "INVOICE") + * @param {Object} options.segments - Collection of ID segments to include + * @param {string[]} options.segments.codes - Array of short codes (e.g., region codes, department codes) + * @param {number} options.segments.year - Year to include in the ID + * @param {number} options.segments.sequentialDigits - Number of digits for sequential number + * @param {boolean} options.segments.includeDate - Whether to include current date + * @param {string} options.segments.dateFormat - Format for date (e.g., "yyyy-MM-dd", "dd/MM/yyyy") + * @param {boolean} options.segments.includeTime - Whether to include timestamp + * @param {string} options.format - Custom format string for ID structure + * @param {string} options.separator - Character to separate ID components + * @param {boolean} options.upperCase - Convert result to uppercase + * @returns {string} - Generated custom ID + */ + +/** + * Generate a unique ID with multiple options to reduce collision risk + */ +export function generateId( + options: { + prefix?: string; + segments?: { + codes?: string[]; + year?: number; + sequentialDigits?: number; + includeDate?: boolean; + dateFormat?: string; + includeTime?: boolean; + includeMilliseconds?: boolean; + }; + format?: string; + separator?: string; + upperCase?: boolean; + randomSequence?: boolean; + uniquenessStrategy?: 'uuid' | 'timestamp' | 'counter' | 'hash'; + retryOnCollision?: boolean; + maxRetries?: number; + } = {} +): string { + // Default options + const config = { + prefix: options.prefix || 'ID', + segments: { + codes: options.segments?.codes || [], + year: options.segments?.year, + sequentialDigits: options.segments?.sequentialDigits || 6, // Increased to 6 + includeDate: options.segments?.includeDate ?? false, + dateFormat: options.segments?.dateFormat || 'yyyyMMdd', + includeTime: options.segments?.includeTime ?? false, + includeMilliseconds: options.segments?.includeMilliseconds ?? false, + }, + format: options.format || null, + separator: options.separator || '-', + upperCase: options.upperCase ?? false, + randomSequence: options.randomSequence ?? true, + uniquenessStrategy: options.uniquenessStrategy || 'timestamp', + retryOnCollision: options.retryOnCollision ?? true, + maxRetries: options.maxRetries || 10, + }; + + // Static counter for sequential IDs (module-level) + if (typeof globalThis.__idCounter === 'undefined') { + globalThis.__idCounter = 0; + } + + // Get current date and time with high precision + const now = new Date(); + + // Format date based on selected format + let dateString = ''; + if (config.segments.includeDate) { + dateString = format(now, config.segments.dateFormat); + } + + // Format time if included (with higher precision) + let timeString = ''; + if (config.segments.includeTime) { + timeString = format(now, 'HHmmss'); + + // Add milliseconds for even more uniqueness + if (config.segments.includeMilliseconds) { + timeString += now.getMilliseconds().toString().padStart(3, '0'); + } + } + + // Generate sequence based on strategy + let sequentialNum: string; + + switch (config.uniquenessStrategy) { + case 'uuid': + // Use first part of UUID for high uniqueness + sequentialNum = uuidv4().split('-')[0]; + break; + + case 'timestamp': + // Use high-precision timestamp + sequentialNum = `${now.getTime()}${Math.floor(Math.random() * 1000)}`; + sequentialNum = sequentialNum.slice(-config.segments.sequentialDigits); + break; + + case 'counter': + // Use an incrementing counter + sequentialNum = (++globalThis.__idCounter) + .toString() + .padStart(config.segments.sequentialDigits, '0'); + break; + + case 'hash': + // Create a hash from the current time and options + const hashSource = `${now.getTime()}-${JSON.stringify(options)}-${Math.random()}`; + const hash = crypto.createHash('sha256').update(hashSource).digest('hex'); + sequentialNum = hash.substring(0, config.segments.sequentialDigits); + break; + + default: + // Standard random sequence with improved randomness + if (config.randomSequence) { + const randomBytes = crypto.randomBytes(4); + const randomNum = parseInt(randomBytes.toString('hex'), 16); + sequentialNum = ( + randomNum % Math.pow(10, config.segments.sequentialDigits) + ) + .toString() + .padStart(config.segments.sequentialDigits, '0'); + } else { + sequentialNum = (++globalThis.__idCounter) + .toString() + .padStart(config.segments.sequentialDigits, '0'); + } + } + + // Prepare all components + const components = { + prefix: config.prefix, + codes: config.segments.codes.join(config.separator), + year: config.segments.year || format(now, 'yyyy'), + sequence: sequentialNum, + date: dateString, + time: timeString, + }; + + // Build the ID based on custom format if provided + let result: string; + + if (config.format) { + let customID = config.format; + for (const [key, value] of Object.entries(components)) { + if (value) { + const placeholder = `{${key}}`; + customID = customID.replace(placeholder, String(value)); + } + } + // Clean up any unused placeholders + customID = customID.replace(/{[^}]+}/g, ''); + // Clean up any consecutive separators + const escapedSeparator = config.separator.replace( + /[-\/\\^$*+?.()|[\]{}]/g, + '\\$&' + ); + const separatorRegex = new RegExp(`${escapedSeparator}+`, 'g'); + customID = customID.replace(separatorRegex, config.separator); + // Remove leading/trailing separators + customID = customID.replace( + new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), + '' + ); + + result = config.upperCase ? customID.toUpperCase() : customID; + } else { + // Default structured build if no format specified + const parts = []; + + if (components.prefix) parts.push(components.prefix); + if (components.codes) parts.push(components.codes); + if (components.year) parts.push(components.year); + if (components.sequence) parts.push(components.sequence); + if (components.date) parts.push(components.date); + if (components.time) parts.push(components.time); + + result = parts.join(config.separator); + if (config.upperCase) result = result.toUpperCase(); + } + + // Check for collisions and retry if necessary + if (config.retryOnCollision) { + let retryCount = 0; + let originalResult = result; + + while (usedIdRegistry.has(result) && retryCount < config.maxRetries) { + retryCount++; + // Try adding a unique suffix + const suffix = crypto.randomBytes(2).toString('hex'); + result = `${originalResult}${config.separator}${suffix}`; + } + + if (retryCount >= config.maxRetries) { + console.warn( + `Warning: Max ID generation retries (${config.maxRetries}) reached for prefix ${config.prefix}` + ); + } + } + + // Register this ID to prevent future duplicates + usedIdRegistry.add(result); + + // Periodically clean up the registry to prevent memory leaks (optional) + if (usedIdRegistry.size > 10000) { + // This is a simple implementation - in production you might want a more sophisticated strategy + const entriesToKeep = Array.from(usedIdRegistry).slice(-5000); + usedIdRegistry.clear(); + entriesToKeep.forEach((id) => usedIdRegistry.add(id)); + } + + return result; +} + +/** + * Gets the last ID from a specified table and column. + * @param tableName - The name of the table to query. + * @param columnName - The column containing the IDs. + * @returns The last ID as a string, or null if no records exist. + */ +export async function getLastId( + tableName: string, + columnName: string +): Promise { + try { + const result = await db.$queryRawUnsafe( + `SELECT ${columnName} FROM ${tableName} ORDER BY ${columnName} DESC LIMIT 1` + ); + + if (Array.isArray(result) && result.length > 0) { + return result[0][columnName]; + } + } catch (error) { + console.error('Error fetching last ID:', error); + } + + return null; +} + +/** + * Generates a unique code based on the provided name. + * @param name - The name to generate the code from. + * @returns The generated code. + */ +export function generateCode(name: string): string { + const words = name.toUpperCase().split(' '); + let code = ''; + + if (name.length <= 3) { + code = name.toUpperCase(); + } else if (words.length > 1) { + code = words + .map((w) => w[0]) + .join('') + .padEnd(3, 'X') + .slice(0, 3); + } else { + const nameClean = name.replace(/[aeiou]/gi, ''); + code = (nameClean.slice(0, 3) || name.slice(0, 3)).toUpperCase(); + } + + return code; +} + +/** + * Generates a unique sequential ID based on a base string and existing codes. + * @param base - The base string to generate the ID from. + * @param existingCodes - A set of existing codes to check against. + * @returns The generated unique sequential ID. + */ +export function getLatestSequentialId( + base: string, + existingCodes: Set +): string { + let attempt = 1; + let newCode = base; + + while (existingCodes.has(newCode)) { + newCode = base.slice(0, 2) + attempt; + attempt++; + } + return newCode; +}