diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index 987a19d..6dbc101 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -15,11 +15,10 @@ import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts'; // Used to track generated IDs const usedIdRegistry = new Set(); - -// Add type definition for global counter and registry +// Add type definitions for global counters declare global { var __idCounter: number; - var __idRegistry: Record; + var __idCounterRegistry: Record; } /** @@ -376,432 +375,267 @@ function formatDateV2(date: Date, formatStr: string): string { .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 async 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; - storage?: 'memory' | 'localStorage' | 'database'; - tableName?: string; // Added table name for database interactions - } = {} -): Promise { - if (!options.uniquenessStrategy && options.randomSequence === false) { - options.uniquenessStrategy = 'counter'; - } +// /** +// * 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, - 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, - storage: options.storage || 'memory', - tableName: options.tableName - }; +// 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, +// }; - if (typeof globalThis.__idCounter === 'undefined') { - globalThis.__idCounter = 0; - } +// // Initialize global counter if not exists +// if (typeof globalThis.__idCounter === 'undefined') { +// globalThis.__idCounter = 0; +// } - const now = new Date(); +// const now = new Date(); - let dateString = ''; - if (config.segments.includeDate) { - dateString = format(now, config.segments.dateFormat); - } +// // Generate date string if needed +// let dateString = ''; +// if (config.segments.includeDate) { +// dateString = format(now, config.segments.dateFormat); +// } - let timeString = ''; - if (config.segments.includeTime) { - timeString = format(now, 'HHmmss'); - if (config.segments.includeMilliseconds) { - timeString += now.getMilliseconds().toString().padStart(3, '0'); - } - } +// // 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'); +// } +// } - 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': - const lastId = await getLastId(config.prefix, { - separator: config.separator, - storage: config.storage, - tableName: config.tableName - }); - - let counterStart = 0; - - if (lastId !== null) { - const parts = lastId.split(config.separator); - const lastPart = parts[parts.length - 1]; - - if (/^\d+$/.test(lastPart)) { - counterStart = parseInt(lastPart, 10); - } else { - for (let i = parts.length - 1; i >= 0; i--) { - if (/^\d+$/.test(parts[i])) { - counterStart = parseInt(parts[i], 10); - break; - } - } - } - } - - const nextCounter = counterStart + 1; - globalThis.__idCounter = nextCounter; - - sequentialNum = nextCounter.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 { - const lastId = await getLastId(config.prefix, { - separator: config.separator, - storage: config.storage, - tableName: config.tableName - }); - - let counterStart = 0; - - if (lastId !== null) { - const parts = lastId.split(config.separator); - const lastPart = parts[parts.length - 1]; - - if (/^\d+$/.test(lastPart)) { - counterStart = parseInt(lastPart, 10); - } else { - for (let i = parts.length - 1; i >= 0; i--) { - if (/^\d+$/.test(parts[i])) { - counterStart = parseInt(parts[i], 10); - break; - } - } - } - } - - const nextCounter = counterStart + 1; - globalThis.__idCounter = nextCounter; - - sequentialNum = nextCounter.toString().padStart(config.segments.sequentialDigits, '0'); - } - } - } catch (error) { - console.error('Error generating sequential number:', error); - sequentialNum = `${now.getTime()}`.slice(-config.segments.sequentialDigits); - } +// // 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); +// } - 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'); - } - } else { - yearValue = format(now, 'yyyy'); - } +// // 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'); +// } - const components = { - prefix: config.prefix, - codes: - config.segments.codes.length > 0 - ? config.segments.codes.join(config.separator) - : '', - year: yearValue, - sequence: sequentialNum, - date: dateString, - time: timeString, - }; +// // 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; +// 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( - new RegExp(placeholder, 'g'), - String(value) - ); - } - } - customID = customID.replace(/{[^}]+}/g, ''); +// // 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, ''); - 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'), - '' - ); +// // 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 { - 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 = 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(); - } +// result = parts.join(config.separator); +// if (config.upperCase) result = result.toUpperCase(); +// } - if (config.retryOnCollision) { - let retryCount = 0; - let originalResult = result; +// // 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); - result = `${originalResult}${config.separator}${Date.now().toString(36)}`; - } - } +// 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}` - ); - } - } +// if (retryCount >= config.maxRetries) { +// console.warn( +// `Warning: Max ID generation retries (${config.maxRetries}) reached for prefix ${config.prefix}` +// ); +// } +// } - usedIdRegistry.add(result); - if (usedIdRegistry.size > 10000) { - const entriesToKeep = Array.from(usedIdRegistry).slice(-5000); - usedIdRegistry.clear(); - entriesToKeep.forEach((id) => usedIdRegistry.add(id)); - } +// // 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)); +// } - await updateLastId(config.prefix, result, { - storage: config.storage, - tableName: config.tableName - }); - - return result.trim(); -} +// return result.trim(); +// } /** - * Retrieves the last generated ID for a specific prefix - * Used by generateId to determine the next sequential number - * - * @param {string} prefix - The prefix to look up (e.g., "INVOICE", "USER") - * @param {Object} options - Additional options - * @param {string} options.separator - The separator used in IDs (must match generateId) - * @param {boolean} options.extractFullSequence - Whether to extract the full sequence or just the numeric part - * @param {string} options.storage - Storage method ('memory', 'localStorage', 'database') - * @returns {string|null} - Returns the last ID or null if none exists + * 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( - prefix: string, - options: { - separator?: string; - extractFullSequence?: boolean; - storage?: 'memory' | 'localStorage' | 'database'; - tableName?: string; - } = {} + tableName: string, + columnName: string ): Promise { - const config = { - separator: options.separator || '-', - extractFullSequence: options.extractFullSequence ?? false, - storage: options.storage || 'memory', - tableName: options.tableName - }; + try { + const result = await db.$queryRawUnsafe( + `SELECT ${columnName} FROM ${tableName} ORDER BY ${columnName} DESC LIMIT 1` + ); - if (typeof globalThis.__idRegistry === 'undefined') { - globalThis.__idRegistry = {}; + if (Array.isArray(result) && result.length > 0) { + return result[0][columnName]; + } + } catch (error) { + console.error('Error fetching last ID:', error); } - const normalizedPrefix = prefix.toUpperCase(); - - let lastId: string | null = null; - - switch (config.storage) { - case 'localStorage': - try { - const storedIds = localStorage.getItem('customIdRegistry'); - if (storedIds) { - const registry = JSON.parse(storedIds); - lastId = registry[normalizedPrefix] || null; - } - } catch (error) { - console.error('Error accessing localStorage:', error); - lastId = globalThis.__idRegistry[normalizedPrefix] || null; - } - break; - - case 'database': - if (!config.tableName) { - console.warn('Table name not provided for database storage. Falling back to memory storage.'); - lastId = globalThis.__idRegistry[normalizedPrefix] || null; - break; - } - - try { - const result = await db.$queryRawUnsafe<{id: string}[]>( - `SELECT id FROM ${config.tableName} - WHERE id LIKE $1 - ORDER BY id DESC - LIMIT 1`, - `${normalizedPrefix}%` - ); - - if (result && result.length > 0) { - lastId = result[0].id; - } - } catch (error) { - console.error(`Error querying database for last ID in ${config.tableName}:`, error); - lastId = globalThis.__idRegistry[normalizedPrefix] || null; - } - break; - - case 'memory': - default: - lastId = globalThis.__idRegistry[normalizedPrefix] || null; - break; - } - - return lastId; -} - -/** - * Updates the last ID record for a specific prefix - * Should be called by generateId after creating a new ID - * - * @param {string} prefix - The prefix to update - * @param {string} id - The newly generated ID to store - * @param {Object} options - Additional options (matching getLastId options) - */ -export async function updateLastId( - prefix: string, - id: string, - options: { - storage?: 'memory' | 'localStorage' | 'database'; - tableName?: string; - } = {} -): Promise { - const config = { - storage: options.storage || 'memory', - tableName: options.tableName - }; - - if (typeof globalThis.__idRegistry === 'undefined') { - globalThis.__idRegistry = {}; - } - - const normalizedPrefix = prefix.toUpperCase(); - - switch (config.storage) { - case 'localStorage': - try { - let registry: Record = {}; - const storedIds = localStorage.getItem('customIdRegistry'); - - if (storedIds) { - registry = JSON.parse(storedIds) as Record; - } - - registry[normalizedPrefix] = id; - localStorage.setItem('customIdRegistry', JSON.stringify(registry)); - - globalThis.__idRegistry[normalizedPrefix] = id; - } catch (error) { - console.error('Error updating localStorage:', error); - globalThis.__idRegistry[normalizedPrefix] = id; - } - break; - - case 'database': - globalThis.__idRegistry[normalizedPrefix] = id; - - if (config.tableName) { - try { - // Optional: Update a dedicated ID tracking table if you have one - } catch (error) { - console.error(`Error updating ID registry in database:`, error); - } - } - break; - - case 'memory': - default: - globalThis.__idRegistry[normalizedPrefix] = id; - break; - } + return null; } /** @@ -925,16 +759,18 @@ export const getDistrictName = (districtId: string): string => { * Format number with commas or abbreviate large numbers */ export function formatNumber(num?: number): string { - if (num === undefined || num === null) return "N/A"; - + 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(); } @@ -993,3 +829,345 @@ export function getTimeAgo(timestamp: string | Date) { 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(); +} diff --git a/sigap-website/prisma/backups/20250421115005_remote_schema.sql b/sigap-website/prisma/backups/20250421115005_remote_schema.sql index b9b37f0..b70dd19 100644 --- a/sigap-website/prisma/backups/20250421115005_remote_schema.sql +++ b/sigap-website/prisma/backups/20250421115005_remote_schema.sql @@ -316,12 +316,12 @@ using ((bucket_id = 'avatars'::text)); -drop type "gis"."geometry_dump"; +-- drop type "gis"."geometry_dump"; -drop type "gis"."valid_detail"; +-- drop type "gis"."valid_detail"; -create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); +-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); -create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); +-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); diff --git a/sigap-website/prisma/migrations/20250505171530_delete_phone_field_on_units/migration.sql b/sigap-website/prisma/migrations/20250505171530_delete_phone_field_on_units/migration.sql new file mode 100644 index 0000000..e2f15eb --- /dev/null +++ b/sigap-website/prisma/migrations/20250505171530_delete_phone_field_on_units/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `phone` on the `units` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "units" DROP COLUMN "phone"; diff --git a/sigap-website/prisma/migrations/20250505171603_add_field_phone_on_units/migration.sql b/sigap-website/prisma/migrations/20250505171603_add_field_phone_on_units/migration.sql new file mode 100644 index 0000000..59237c5 --- /dev/null +++ b/sigap-website/prisma/migrations/20250505171603_add_field_phone_on_units/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "units" ADD COLUMN "phone" TEXT; diff --git a/sigap-website/prisma/seeds/crime-category.ts b/sigap-website/prisma/seeds/crime-category.ts index fcbc6d3..3cd2da0 100644 --- a/sigap-website/prisma/seeds/crime-category.ts +++ b/sigap-website/prisma/seeds/crime-category.ts @@ -1,6 +1,6 @@ // prisma/seeds/CrimeCategoriesSeeder.ts -import { generateId } from "../../app/_utils/common"; -import { PrismaClient } from "@prisma/client"; +import { generateId, generateIdWithDbCounter } from '../../app/_utils/common'; +import { PrismaClient } from '@prisma/client'; import { crimeCategoriesData } from '../data/jsons/crime-category'; import path from 'path'; @@ -36,16 +36,14 @@ export class CrimeCategoriesSeeder { const data = XLSX.utils.sheet_to_json(sheet) as ICrimeCategory[]; for (const category of crimeCategoriesData) { - const newId = await generateId({ + const newId = await generateIdWithDbCounter('crime_categories', { prefix: 'CC', segments: { sequentialDigits: 4, }, - randomSequence: false, - uniquenessStrategy: 'counter', + format: '{prefix}-{sequence}', separator: '-', - tableName: 'crime_categories', - storage: 'database', + uniquenessStrategy: 'counter', }); await this.prisma.crime_categories.create({ diff --git a/sigap-website/prisma/seeds/crime-incidents.ts b/sigap-website/prisma/seeds/crime-incidents.ts index 731b220..a2f5fdb 100644 --- a/sigap-website/prisma/seeds/crime-incidents.ts +++ b/sigap-website/prisma/seeds/crime-incidents.ts @@ -4,7 +4,7 @@ import { crime_status, crimes, } from '@prisma/client'; -import { generateId } from '../../app/_utils/common'; +import { generateId, generateIdWithDbCounter } from '../../app/_utils/common'; import { createClient } from '../../app/_utils/supabase/client'; import * as fs from 'fs'; import * as path from 'path'; @@ -64,6 +64,94 @@ export class CrimeIncidentsSeeder { }); } + /** + * Generates well-distributed points within a district's area + * @param centerLat - The center latitude of the district + * @param centerLng - The center longitude of the district + * @param landArea - Land area in square km + * @param numPoints - Number of points to generate + * @returns Array of {latitude, longitude, radius} points + */ + private generateDistributedPoints( + centerLat: number, + centerLng: number, + landArea: number, + numPoints: number + ): Array<{ latitude: number; longitude: number; radius: number }> { + const points = []; + + // Calculate a reasonable radius based on land area + // Using square root of area as an approximation of district "radius" + const areaFactor = Math.sqrt(landArea) / 100; + const baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03)); + + // Create a grid-based distribution for better coverage + const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5)); // Slightly larger grid for variety + + // Define district bounds approximately + // 0.1 degrees is roughly 11km at equator + const estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111; + const bounds = { + minLat: centerLat - estimatedDistrictRadius, + maxLat: centerLat + estimatedDistrictRadius, + minLng: centerLng - estimatedDistrictRadius, + maxLng: centerLng + estimatedDistrictRadius, + }; + + const latStep = (bounds.maxLat - bounds.minLat) / gridSize; + const lngStep = (bounds.maxLng - bounds.minLng) / gridSize; + + // Generate points in each grid cell with some randomness + let totalPoints = 0; + for (let i = 0; i < gridSize && totalPoints < numPoints; i++) { + for (let j = 0; j < gridSize && totalPoints < numPoints; j++) { + // Base position within the grid cell + const cellLat = + bounds.minLat + (i + 0.2 + Math.random() * 0.6) * latStep; + const cellLng = + bounds.minLng + (j + 0.2 + Math.random() * 0.6) * lngStep; + + // Distance from center (for radius reference) + const latDiff = cellLat - centerLat; + const lngDiff = cellLng - centerLng; + const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff); + + // Add some randomness to avoid perfect grid pattern + const jitter = baseRadius * 0.2; + const latitude = cellLat + (Math.random() * 2 - 1) * jitter; + const longitude = cellLng + (Math.random() * 2 - 1) * jitter; + + points.push({ + latitude, + longitude, + radius: distance * 111000, // Convert to meters (approx) + }); + + totalPoints++; + } + } + + // Add some completely random points for diversity + while (points.length < numPoints) { + const latitude = + centerLat + (Math.random() * 2 - 1) * estimatedDistrictRadius; + const longitude = + centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius; + + const latDiff = latitude - centerLat; + const lngDiff = longitude - centerLng; + const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff); + + points.push({ + latitude, + longitude, + radius: distance * 111000, // Convert to meters (approx) + }); + } + + return points; + } + async run(): Promise { console.log('🌱 Seeding crime incidents data...'); @@ -161,66 +249,16 @@ export class CrimeIncidentsSeeder { return []; } - // Generate multiple coordinates for this district to add more variety - // We'll create a pool of 5-10 potential locations and select from them randomly for each incident - const locationPool = []; - const numLocations = Math.floor(Math.random() * 6) + 5; // 5-10 locations + // Generate a variable number of incidents between 10 and 25 for more variability + const numLocations = Math.floor(Math.random() * 16) + 10; // 10-25 locations - // Scale radius based on district land area if available - // This creates more realistic distribution based on district size - let baseRadius = 0.02; // Default ~2km - if (geo.land_area) { - // Adjust radius based on land area - larger districts get larger radius - // Square root of area provides a reasonable scale factor - const areaFactor = Math.sqrt(geo.land_area) / 100; - baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03)); - } - - for (let i = 0; i < numLocations; i++) { - // Create more varied locations by using different radiuses - const radiusVariation = Math.random() * 0.5 + 0.5; // 50% to 150% of base radius - const radius = baseRadius * radiusVariation; - - // Different angle for each location - const angle = Math.random() * Math.PI * 2; - - // Use different distance distribution patterns - // Some close to center, some at middle distance, some near the edge - let distance; - const patternType = Math.floor(Math.random() * 3); - switch (patternType) { - case 0: // Close to center - distance = Math.random() * 0.4 * radius; - break; - case 1: // Middle range - distance = (0.4 + Math.random() * 0.3) * radius; - break; - case 2: // Edge of district - distance = (0.7 + Math.random() * 0.3) * radius; - break; - } - - if (!distance || !angle) { - console.error( - `Invalid distance or angle for location generation, skipping.` - ); - continue; - } - - // Calculate offset with improved approximation - const latOffset = distance * Math.cos(angle); - const lngOffset = distance * Math.sin(angle); - - // Apply offset to base coordinates - const latitude = geo.latitude + latOffset; - const longitude = geo.longitude + lngOffset; - - locationPool.push({ - latitude, - longitude, - radius: distance * 1000, // Convert to meters for reference - }); - } + // Generate distributed locations using our new utility function + const locationPool = this.generateDistributedPoints( + geo.latitude, + geo.longitude, + geo.land_area || 100, // Default to 100 km² if not available + numLocations + ); // List of common street names in Jember with more variety const jemberStreets = [ @@ -409,20 +447,21 @@ export class CrimeIncidentsSeeder { } // Generate a unique ID for the incident - const incidentId = await generateId({ - prefix: 'CI', - segments: { - codes: [district.cities.id], - sequentialDigits: 4, - year, + const incidentId = await generateIdWithDbCounter( + 'crime_incidents', + { + prefix: 'CI', + segments: { + codes: [district.city_id], + sequentialDigits: 4, + year, + }, + format: '{prefix}-{codes}-{sequence}-{year}', + separator: '-', + uniquenessStrategy: 'counter', }, - format: '{prefix}-{codes}-{sequence}-{year}', - separator: '-', - randomSequence: false, - uniquenessStrategy: 'counter', - storage: 'database', - tableName: 'crime_incidents', - }); + /(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter + ); // Determine status based on crime_cleared // If i < crimesCleared, this incident is resolved, otherwise unresolved diff --git a/sigap-website/prisma/seeds/crimes.ts b/sigap-website/prisma/seeds/crimes.ts index f099870..4545697 100644 --- a/sigap-website/prisma/seeds/crimes.ts +++ b/sigap-website/prisma/seeds/crimes.ts @@ -8,7 +8,7 @@ import { import fs from 'fs'; import path from 'path'; import { parse } from 'csv-parse/sync'; -import { generateId } from '../../app/_utils/common'; +import { generateId, generateIdWithDbCounter } from '../../app/_utils/common'; export class CrimesSeeder { constructor(private prisma: PrismaClient) {} @@ -177,20 +177,23 @@ export class CrimesSeeder { const year = parseInt(record.year); // Create a unique ID for monthly crime data - const crimeId = await generateId({ - prefix: 'CR', - segments: { - codes: [city.id], - sequentialDigits: 4, - year, + const crimeId = await generateIdWithDbCounter( + 'crimes', + { + prefix: 'CR', + segments: { + codes: [city.id], + sequentialDigits: 4, + year, + }, + format: '{prefix}-{codes}-{sequence}-{year}', + separator: '-', + uniquenessStrategy: 'counter', }, - format: '{prefix}-{codes}-{sequence}-{year}', - separator: '-', - randomSequence: false, - uniquenessStrategy: 'counter', - storage: 'database', - tableName: 'crimes', - }); + /(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter + ); + + console.log('Creating crime ID:', crimeId); await this.prisma.crimes.create({ data: { @@ -259,20 +262,36 @@ export class CrimesSeeder { } // Create a unique ID for yearly crime data - const crimeId = await generateId({ - prefix: 'CR', - segments: { - codes: [city.id], - sequentialDigits: 4, - year, + // const crimeId = await generateId({ + // prefix: 'CR', + // segments: { + // codes: [city.id], + // sequentialDigits: 4, + // year, + // }, + // format: '{prefix}-{codes}-{sequence}-{year}', + // separator: '-', + // randomSequence: false, + // uniquenessStrategy: 'counter', + // storage: 'database', + // tableName: 'crimes', + // }); + + const crimeId = await generateIdWithDbCounter( + 'crimes', + { + prefix: 'CR', + segments: { + codes: [city.id], + sequentialDigits: 4, + year, + }, + format: '{prefix}-{codes}-{sequence}-{year}', + separator: '-', + uniquenessStrategy: 'counter', }, - format: '{prefix}-{codes}-{sequence}-{year}', - separator: '-', - randomSequence: false, - uniquenessStrategy: 'counter', - storage: 'database', - tableName: 'crimes', - }); + /(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter + ); await this.prisma.crimes.create({ data: { @@ -337,19 +356,20 @@ export class CrimesSeeder { } // Create a unique ID for all-year summary data - const crimeId = await generateId({ - prefix: 'CR', - segments: { - codes: [city.id], - sequentialDigits: 4, + const crimeId = await generateIdWithDbCounter( + 'crimes', + { + prefix: 'CR', + segments: { + codes: [city.id], + sequentialDigits: 4, + }, + format: '{prefix}-{codes}-{sequence}', + separator: '-', + uniquenessStrategy: 'counter', }, - format: '{prefix}-{codes}-{sequence}', - separator: '-', - randomSequence: false, - uniquenessStrategy: 'counter', - storage: 'database', - tableName: 'crimes', - }); + /(\d{4})$/ // Pattern to extract the 4-digit counter at the end + ); await this.prisma.crimes.create({ data: { diff --git a/sigap-website/prisma/seeds/units.ts b/sigap-website/prisma/seeds/units.ts index 4deeeb7..9570f36 100644 --- a/sigap-website/prisma/seeds/units.ts +++ b/sigap-website/prisma/seeds/units.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; import { createClient } from '../../app/_utils/supabase/client'; -import { generateId } from '../../app/_utils/common'; +import { generateId, generateIdWithDbCounter } from '../../app/_utils/common'; // Interface untuk data Excel row interface ExcelRow { @@ -89,17 +89,14 @@ export class UnitSeeder { const address = location.address; const phone = location.telepon?.replace(/-/g, ''); - const newId = await generateId({ + const newId = await generateIdWithDbCounter('units', { prefix: 'UT', - format: '{prefix}-{sequence}', - separator: '-', - randomSequence: false, - uniquenessStrategy: 'counter', segments: { sequentialDigits: 4, }, - storage: 'database', - tableName: 'units', + format: '{prefix}-{sequence}', + separator: '-', + uniquenessStrategy: 'counter', }); let locationData: CreateLocationDto = { @@ -154,17 +151,14 @@ export class UnitSeeder { const address = location.address; const phone = location.telepon?.replace(/-/g, ''); - const newId = await generateId({ + const newId = await generateIdWithDbCounter('units', { prefix: 'UT', - format: '{prefix}-{sequence}', - separator: '-', - randomSequence: false, - uniquenessStrategy: 'counter', segments: { sequentialDigits: 4, }, - storage: 'database', - tableName: 'units', + format: '{prefix}-{sequence}', + separator: '-', + uniquenessStrategy: 'counter', }); const locationData: CreateLocationDto = { diff --git a/sigap-website/supabase/migrations/20250505161323_remote_schema.sql b/sigap-website/supabase/migrations/20250505161323_remote_schema.sql index 9b69d21..af7930e 100644 --- a/sigap-website/supabase/migrations/20250505161323_remote_schema.sql +++ b/sigap-website/supabase/migrations/20250505161323_remote_schema.sql @@ -1,9 +1,9 @@ -drop type "gis"."geometry_dump"; +-- drop type "gis"."geometry_dump"; -drop type "gis"."valid_detail"; +-- drop type "gis"."valid_detail"; -create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); +-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); -create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); +-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry);