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'; import { CRIME_RATE_COLORS } from './const/map'; import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts-geojson'; // Used to track generated IDs const usedIdRegistry = new Set(); // Add type definitions for global counters declare global { var __idCounter: number; var __idCounterRegistry: Record; } /** * Redirects to a specified path with an encoded message as a query parameter. * @param {('error' | 'success')} type - The type of message, either 'error' or 'success'. * @param {string} path - The path to redirect to. * @param {string} message - The message to be encoded and added as a query parameter. * @returns {never} This function doesn't return as it triggers a redirect. */ export function encodedRedirect( type: 'error' | 'success', path: string, message: string ) { return redirect(`${path}?${type}=${encodeURIComponent(message)}`); } /** * Formats a URL by removing any trailing slashes. * @param {string} url - The URL to format. * @returns {string} The formatted URL. */ // 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('/')) { return url; } // Otherwise, ensure it's properly formatted relative to root // Remove any potential duplicated '/dashboard' prefixes if (url.startsWith('dashboard/')) { return '/' + url; } return '/' + url; } /** * Creates a FormData object from the FormData object. * @returns {FormData} The FormData object. */ export function createFormData(): FormData { const data = new FormData(); Object.entries(FormData).forEach(([key, value]) => { if (value) { data.append(key, value); } }); return data; } /** * Generates a unique username based on the provided email address. * * The username is created by combining the local part of the email (before the '@' symbol) * with a randomly generated alphanumeric suffix. * * @param email - The email address to generate the username from. * @returns A string representing the generated username. * * @example * ```typescript * const username = generateUsername("example@gmail.com"); * console.log(username); // Output: "example.abc123" (random suffix will vary) * ``` */ export function generateUsername(email: string): string { const [localPart] = email.split('@'); const randomSuffix = Math.random().toString(36).substring(2, 8); // Generate a random alphanumeric string return `${localPart}.${randomSuffix}`; } /** * Formats a date string to a human-readable format with type safety. * @param date - The date string to format. * @param options - Formatting options or a format string. * @returns The formatted date 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' } ): string => { if (!date) { 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 || '-'; } if (typeof options === 'string') { return format(dateObj, 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; } ) => { if (!navigator.clipboard) { 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'); options?.onError?.(error); return; } 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'); options?.onError?.(error); }); }; /** * Formats a date string to a human-readable format with type safety. * @param date - The date string to format. * @param options - Formatting options or a format string. * @returns The formatted date 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 formatDateWithFallback = ( date: string | Date | undefined | null, options: DateFormatOptions | DateFormatPattern = { format: 'PPpp' } ): string => { if (!date) { 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 || '-'; } if (typeof options === 'string') { return format(dateObj, 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' } ): string => { if (!date) { 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 || '-'; } if (typeof options === 'string') { return format(dateObj, options); } const { format: formatPattern = 'PPpp', locale } = options; return locale ? format(dateObj, formatPattern, { locale }) : format(dateObj, formatPattern); }; /** * Formats a date string to a human-readable format with type safety. * @param date - The date string to format. * @param options - Formatting options or a format string. * @returns The formatted date 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 formatDateWithLocaleAndFallback = ( date: string | Date | undefined | null, options: DateFormatOptions | DateFormatPattern = { format: 'PPpp' } ): string => { if (!date) { 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 || '-'; } if (typeof options === 'string') { return format(dateObj, 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. * @param firstName - The first name. * @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'; }; /** * Generates initials for a user based on their first and last names. * @param firstName - The first name. * @param lastName - The last name. * @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 => { if (firstName && lastName) { return `${firstName[0]}${lastName[0]}`.toUpperCase(); } if (firstName) { return firstName[0].toUpperCase(); } if (email) { return email[0].toUpperCase(); } return 'U'; }; export function calculateUserStats(users: IUserSchema[] | undefined) { if (!users || !Array.isArray(users)) { return { totalUsers: 0, activeUsers: 0, inactiveUsers: 0, activePercentage: '0.0', inactivePercentage: '0.0', }; } const totalUsers = users.length; const activeUsers = users.filter( (user) => !user.banned_until && user.email_confirmed_at ).length; const inactiveUsers = totalUsers - activeUsers; return { totalUsers, activeUsers, inactiveUsers, activePercentage: totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : '0.0', inactivePercentage: totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : '0.0', }; } /** * Generate route with query parameters * @param baseRoute - The base route path * @param params - Object containing query parameters * @returns Formatted route with query parameters */ export const createRoute = ( baseRoute: string, params?: Record ): string => { if (!params || Object.keys(params).length === 0) { return baseRoute; } const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); return `${baseRoute}?${queryString}`; }; // Format date helper function function formatDateV2(date: Date, formatStr: string): string { const pad = (num: number) => String(num).padStart(2, '0'); return formatStr .replace('yyyy', String(date.getFullYear())) .replace('MM', pad(date.getMonth() + 1)) .replace('dd', pad(date.getDate())) .replace('HH', pad(date.getHours())) .replace('mm', pad(date.getMinutes())) .replace('ss', pad(date.getSeconds())); } // /** // * 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 // */ // export function generateId( // options: { // prefix?: string; // segments?: { // codes?: string[]; // year?: number | boolean; // Year diubah menjadi number | boolean // sequentialDigits?: number; // includeDate?: boolean; // dateFormat?: string; // includeTime?: boolean; // includeMilliseconds?: boolean; // }; // format?: string | null; // separator?: string; // upperCase?: boolean; // randomSequence?: boolean; // uniquenessStrategy?: 'uuid' | 'timestamp' | 'counter' | 'hash'; // retryOnCollision?: boolean; // maxRetries?: number; // } = {} // ): string { // // Jika uniquenessStrategy tidak diatur dan randomSequence = false, // // gunakan counter sebagai strategi default // if (!options.uniquenessStrategy && options.randomSequence === false) { // options.uniquenessStrategy = 'counter'; // } // const config = { // prefix: options.prefix || 'ID', // segments: { // codes: options.segments?.codes || [], // year: options.segments?.year, // Akan diproses secara kondisional nanti // sequentialDigits: options.segments?.sequentialDigits || 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, // }; // // Initialize global counter if not exists // if (typeof globalThis.__idCounter === 'undefined') { // globalThis.__idCounter = 0; // } // const now = new Date(); // // Generate date string if needed // let dateString = ''; // if (config.segments.includeDate) { // dateString = format(now, config.segments.dateFormat); // } // // Generate time string if needed // let timeString = ''; // if (config.segments.includeTime) { // timeString = format(now, 'HHmmss'); // if (config.segments.includeMilliseconds) { // timeString += now.getMilliseconds().toString().padStart(3, '0'); // } // } // // Generate sequential number based on uniqueness strategy // let sequentialNum: string; // try { // switch (config.uniquenessStrategy) { // case 'uuid': // sequentialNum = uuidv4().split('-')[0]; // break; // case 'timestamp': // sequentialNum = `${now.getTime()}${Math.floor(Math.random() * 1000)}`; // sequentialNum = sequentialNum.slice(-config.segments.sequentialDigits); // break; // case 'counter': // sequentialNum = (++globalThis.__idCounter) // .toString() // .padStart(config.segments.sequentialDigits, '0'); // break; // case 'hash': // 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: // 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'); // } // } // } catch (error) { // console.error('Error generating sequential number:', error); // // Fallback to timestamp strategy if other methods fail // sequentialNum = `${now.getTime()}`.slice(-config.segments.sequentialDigits); // } // // Determine if year should be included and what value to use // let yearValue = null; // if (config.segments.year !== undefined || config.segments.year != false) { // if (typeof config.segments.year === 'number') { // yearValue = String(config.segments.year); // } else if (config.segments.year === true) { // yearValue = format(now, 'yyyy'); // } // // if year is false, yearValue remains null and won't be included // } else { // // Default behavior (backward compatibility) // yearValue = format(now, 'yyyy'); // } // // Prepare components for ID assembly // const components = { // prefix: config.prefix, // codes: // config.segments.codes.length > 0 // ? config.segments.codes.join(config.separator) // : '', // year: yearValue, // Added the year value to components // sequence: sequentialNum, // date: dateString, // time: timeString, // }; // let result: string; // // Use custom format if provided // if (config.format) { // let customID = config.format; // for (const [key, value] of Object.entries(components)) { // if (value) { // const placeholder = `{${key}}`; // customID = customID.replace( // new RegExp(placeholder, 'g'), // String(value) // ); // } // } // // Remove unused placeholders // customID = customID.replace(/{[^}]+}/g, ''); // // Clean up separators // const escapedSeparator = config.separator.replace( // /[-\/\\^$*+?.()|[\]{}]/g, // '\\$&' // ); // const separatorRegex = new RegExp(`${escapedSeparator}+`, 'g'); // customID = customID.replace(separatorRegex, config.separator); // customID = customID.replace( // new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), // '' // ); // result = config.upperCase ? customID.toUpperCase() : customID; // } else { // // Assemble ID from parts // 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.date) parts.push(components.date); // if (components.time) parts.push(components.time); // if (components.sequence) parts.push(components.sequence); // result = parts.join(config.separator); // if (config.upperCase) result = result.toUpperCase(); // } // // Handle collisions if required // if (config.retryOnCollision) { // let retryCount = 0; // let originalResult = result; // while (usedIdRegistry.has(result) && retryCount < config.maxRetries) { // retryCount++; // try { // const suffix = crypto.randomBytes(2).toString('hex'); // result = `${originalResult}${config.separator}${suffix}`; // } catch (error) { // console.error('Error generating collision suffix:', error); // // Simple fallback if crypto fails // result = `${originalResult}${config.separator}${Date.now().toString(36)}`; // } // } // if (retryCount >= config.maxRetries) { // console.warn( // `Warning: Max ID generation retries (${config.maxRetries}) reached for prefix ${config.prefix}` // ); // } // } // // Register the ID and maintain registry size // usedIdRegistry.add(result); // if (usedIdRegistry.size > 10000) { // const entriesToKeep = Array.from(usedIdRegistry).slice(-5000); // usedIdRegistry.clear(); // entriesToKeep.forEach((id) => usedIdRegistry.add(id)); // } // return result.trim(); // } /** * 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; } /** * Get color and text for a crime rate level */ export function getCrimeRateInfo( rate?: 'low' | 'medium' | 'high' | 'critical' ) { switch (rate) { case 'low': return { color: 'bg-green-100 text-green-800', text: 'Low' }; case 'medium': return { color: 'bg-yellow-100 text-yellow-800', text: 'Medium' }; case 'high': return { color: 'bg-orange-100 text-orange-800', text: 'High' }; case 'critical': return { color: 'bg-red-100 text-red-800', text: 'Critical' }; default: return { color: 'bg-gray-100 text-gray-800', text: 'Unknown' }; } } /** * Get month name from month number (1-12) */ export function getMonthName(month: string | number): string { const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const monthNum = parseInt(month.toString()); if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) { return 'Invalid Month'; } return months[monthNum - 1]; } /** * Format a date into a readable string */ export function formatDateString(date: Date | string): string { if (!date) return 'Unknown Date'; const d = typeof date === 'string' ? new Date(date) : date; return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', }); } // Helper function to get district name from district ID export const getDistrictName = (districtId: string): string => { const feature = districtsGeoJson.features.find( (f) => f.properties?.kode_kec === districtId ); return ( feature?.properties?.nama || feature?.properties?.kecamatan || 'Unknown District' ); }; /** * Format number with commas or abbreviate large numbers */ export function formatNumber(num?: number): string { if (num === undefined || num === null) return 'N/A'; // If number is in the thousands, abbreviate if (num >= 1_000_000) { return (num / 1_000_000).toFixed(1) + 'M'; } if (num >= 1_000) { return (num / 1_000).toFixed(1) + 'K'; } // Otherwise, format with commas return num.toLocaleString(); } export function getIncidentSeverity( incident: any ): 'Low' | 'Medium' | 'High' | 'Critical' { if (!incident) return 'Low'; const category = incident.category || 'Unknown'; const highSeverityCategories = [ 'Pembunuhan', 'Perkosaan', 'Penculikan', 'Lahgun Senpi/Handak/Sajam', 'PTPPO', 'Trafficking In Person', ]; const mediumSeverityCategories = [ 'Penganiayaan Berat', 'Penganiayaan Ringan', 'Pencurian Biasa', 'Curat', 'Curas', 'Curanmor', 'Pengeroyokan', 'PKDRT', 'Penggelapan', 'Pengrusakan', ]; if (highSeverityCategories.includes(category)) return 'High'; if (mediumSeverityCategories.includes(category)) return 'Medium'; if (incident.type === 'Pidana Tertentu') return 'Medium'; return 'Low'; } export function formatMonthKey(monthKey: string): string { const [year, month] = monthKey.split('-').map(Number); return `${getMonthName(month)} ${year}`; } export function getTimeAgo(timestamp: string | Date) { const now = new Date(); const eventTime = new Date(timestamp); const diffMs = now.getTime() - eventTime.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; return 'just now'; } /** * Helper function to extract numeric counter from an ID string * @param id The ID to extract counter from * @param pattern The pattern to identify the counter portion * @returns The numeric counter value */ function extractCounterFromId(id: string, pattern: RegExp): number { const match = id.match(pattern); if (match && match[1]) { return parseInt(match[1], 10); } return 0; } /** * Retrieves the last ID from a specific table and extracts its counter * @param tableName The table to query * @param counterPattern RegExp pattern to extract counter (with capture group) * @param orderByField Field to order by (usually 'id' or 'createdAt') * @returns The last used counter number */ export async function getLastIdCounter( tableName: string, counterPattern: RegExp, orderByField: string = 'id' ): Promise { try { // Dynamic query to get the last record from the specified table const result = await db.$queryRawUnsafe( `SELECT ${orderByField} FROM "${tableName}" ORDER BY "${orderByField}" DESC LIMIT 1` ); // Extract the ID from the result if (result && Array.isArray(result) && result.length > 0) { const lastId = result[0][orderByField]; if (lastId) { return extractCounterFromId(lastId, counterPattern); } } return 0; // No records found, start from 0 } catch (error) { console.error(`Error fetching last ID from ${tableName}:`, error); return 0; // Return 0 on error (will start new sequence) } } /** * Generate an ID with counter continuation from database * @param tableName Prisma table name to check for last ID * @param options ID generation options * @param counterPattern RegExp pattern to extract counter (with capture group) * @returns Generated ID string */ export async function generateIdWithDbCounter( tableName: string, options: { prefix?: string; segments?: { codes?: string[]; year?: number | boolean; sequentialDigits?: number; includeDate?: boolean; dateFormat?: string; includeTime?: boolean; includeMilliseconds?: boolean; }; format?: string | null; separator?: string; upperCase?: boolean; uniquenessStrategy?: 'counter'; retryOnCollision?: boolean; maxRetries?: number; } = {}, counterPattern: RegExp = /(\d+)$/ ): Promise { // Override uniquenessStrategy to ensure we use counter options.uniquenessStrategy = 'counter'; // Initialize the counter registry if it doesn't exist if (!globalThis.__idCounterRegistry) { globalThis.__idCounterRegistry = {} as Record; } // Get the last counter from the database let lastCounter; if (tableName === 'units') { lastCounter = await getLastIdCounter( tableName, counterPattern, 'code_unit' ); } else { lastCounter = await getLastIdCounter(tableName, counterPattern); } // Initialize or update the counter for this specific prefix if (globalThis.__idCounterRegistry[tableName] === undefined) { globalThis.__idCounterRegistry[tableName] = lastCounter; } else { // Ensure the counter is at least as large as the last DB counter globalThis.__idCounterRegistry[tableName] = Math.max( globalThis.__idCounterRegistry[tableName], lastCounter ); } // Store the prefix-specific counter value const currentCounter = globalThis.__idCounterRegistry[tableName]; // Set the global counter to this prefix's counter for the generateId function globalThis.__idCounter = currentCounter; // Generate the ID using the existing function const generatedId = generateId(options); // Update the prefix's counter after generation globalThis.__idCounterRegistry[tableName] = globalThis.__idCounter; return generatedId; } export function generateId( options: { prefix?: string; segments?: { codes?: string[]; year?: number | boolean; sequentialDigits?: number; includeDate?: boolean; dateFormat?: string; includeTime?: boolean; includeMilliseconds?: boolean; }; format?: string | null; separator?: string; upperCase?: boolean; randomSequence?: boolean; uniquenessStrategy?: 'uuid' | 'timestamp' | 'counter' | 'hash'; retryOnCollision?: boolean; maxRetries?: number; } = {} ): string { // Jika uniquenessStrategy tidak diatur dan randomSequence = false, // gunakan counter sebagai strategi default if (!options.uniquenessStrategy && options.randomSequence === false) { options.uniquenessStrategy = 'counter'; } const config = { prefix: options.prefix || 'ID', segments: { codes: options.segments?.codes || [], year: options.segments?.year, // Akan diproses secara kondisional nanti sequentialDigits: options.segments?.sequentialDigits || 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, }; // Initialize global counter if not exists if (typeof globalThis.__idCounter === 'undefined') { globalThis.__idCounter = 0; } const now = new Date(); // Generate date string if needed let dateString = ''; if (config.segments.includeDate) { dateString = format(now, config.segments.dateFormat); } // Generate time string if needed let timeString = ''; if (config.segments.includeTime) { timeString = format(now, 'HHmmss'); if (config.segments.includeMilliseconds) { timeString += now.getMilliseconds().toString().padStart(3, '0'); } } // Generate sequential number based on uniqueness strategy let sequentialNum: string; try { switch (config.uniquenessStrategy) { case 'uuid': sequentialNum = crypto.randomUUID().split('-')[0]; break; case 'timestamp': sequentialNum = `${now.getTime()}${Math.floor(Math.random() * 1000)}`; sequentialNum = sequentialNum.slice(-config.segments.sequentialDigits); break; case 'counter': sequentialNum = (++globalThis.__idCounter) .toString() .padStart(config.segments.sequentialDigits, '0'); break; case 'hash': 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: 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'); } } } catch (error) { console.error('Error generating sequential number:', error); // Fallback to timestamp strategy if other methods fail sequentialNum = `${now.getTime()}`.slice(-config.segments.sequentialDigits); } // Determine if year should be included and what value to use let yearValue = null; if (config.segments.year !== undefined && config.segments.year !== false) { if (typeof config.segments.year === 'number') { yearValue = String(config.segments.year); } else if (config.segments.year === true) { yearValue = format(now, 'yyyy'); } // if year is false, yearValue remains null and won't be included } else { // Default behavior (backward compatibility) yearValue = format(now, 'yyyy'); } // Prepare components for ID assembly const components = { prefix: config.prefix, codes: config.segments.codes.length > 0 ? config.segments.codes.join(config.separator) : '', year: yearValue, // Added the year value to components sequence: sequentialNum, date: dateString, time: timeString, }; let result: string; // Use custom format if provided if (config.format) { let customID = config.format; for (const [key, value] of Object.entries(components)) { if (value) { const placeholder = `{${key}}`; customID = customID.replace( new RegExp(placeholder, 'g'), String(value) ); } } // Remove unused placeholders customID = customID.replace(/{[^}]+}/g, ''); // Clean up separators const escapedSeparator = config.separator.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ); const separatorRegex = new RegExp(`${escapedSeparator}+`, 'g'); customID = customID.replace(separatorRegex, config.separator); customID = customID.replace( new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), '' ); result = config.upperCase ? customID.toUpperCase() : customID; } else { // Assemble ID from parts 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.date) parts.push(components.date); if (components.time) parts.push(components.time); if (components.sequence) parts.push(components.sequence); result = parts.join(config.separator); if (config.upperCase) result = result.toUpperCase(); } // Handle collisions if required if (config.retryOnCollision) { let retryCount = 0; let originalResult = result; while (usedIdRegistry.has(result) && retryCount < config.maxRetries) { retryCount++; try { const suffix = crypto.randomBytes(2).toString('hex'); result = `${originalResult}${config.separator}${suffix}`; } catch (error) { console.error('Error generating collision suffix:', error); // Simple fallback if crypto fails result = `${originalResult}${config.separator}${Date.now().toString(36)}`; } } if (retryCount >= config.maxRetries) { console.warn( `Warning: Max ID generation retries (${config.maxRetries}) reached for prefix ${config.prefix}` ); } } // Register the ID and maintain registry size usedIdRegistry.add(result); if (usedIdRegistry.size > 10000) { const entriesToKeep = Array.from(usedIdRegistry).slice(-5000); usedIdRegistry.clear(); entriesToKeep.forEach((id) => usedIdRegistry.add(id)); } return result.trim(); }