From e891df87d0a932503fd7c1b6229f18b01888ed88 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Tue, 6 May 2025 01:21:04 +0700 Subject: [PATCH] Refactor seeding scripts to utilize generateIdWithDbCounter for unique ID generation across crime categories, incidents, and units. Implement a new utility function for generating distributed points within district areas to enhance incident location variability. Comment out unnecessary type drops and creations in SQL migration files for clarity. Add migration scripts to drop and re-add the phone field in the units table, ensuring data integrity during schema updates. --- sigap-website/app/_utils/common.ts | 992 +++++++++++------- .../backups/20250421115005_remote_schema.sql | 8 +- .../migration.sql | 8 + .../migration.sql | 2 + sigap-website/prisma/seeds/crime-category.ts | 12 +- sigap-website/prisma/seeds/crime-incidents.ts | 185 ++-- sigap-website/prisma/seeds/crimes.ts | 98 +- sigap-website/prisma/seeds/units.ts | 24 +- .../20250505161323_remote_schema.sql | 8 +- 9 files changed, 788 insertions(+), 549 deletions(-) create mode 100644 sigap-website/prisma/migrations/20250505171530_delete_phone_field_on_units/migration.sql create mode 100644 sigap-website/prisma/migrations/20250505171603_add_field_phone_on_units/migration.sql 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);