@jmruthers/pace-core 0.5.186 → 0.5.187

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/dist/{DataTable-Z9NLVJh0.d.ts → DataTable-IVYljGJ6.d.ts} +1 -1
  2. package/dist/{DataTable-IX2NBUTP.js → DataTable-K3RJRSOX.js} +7 -7
  3. package/dist/{PublicPageProvider-DIzEzwKl.d.ts → PublicPageProvider-DrLDztHt.d.ts} +211 -106
  4. package/dist/{UnifiedAuthProvider-A4BCQRJY.js → UnifiedAuthProvider-B76OWOAT.js} +2 -2
  5. package/dist/{api-BMFCXVQX.js → api-YP7XD5L6.js} +3 -3
  6. package/dist/{audit-WRS3KJKI.js → audit-B5P6FFIR.js} +2 -2
  7. package/dist/{chunk-445GEP27.js → chunk-3IC5WCMO.js} +33 -8
  8. package/dist/chunk-3IC5WCMO.js.map +1 -0
  9. package/dist/{chunk-OALXJH4Y.js → chunk-3NFNJOO7.js} +8 -8
  10. package/dist/chunk-3NFNJOO7.js.map +1 -0
  11. package/dist/{chunk-FSFQFJCU.js → chunk-63FOKYGO.js} +174 -6
  12. package/dist/chunk-63FOKYGO.js.map +1 -0
  13. package/dist/{chunk-TC7D3CR3.js → chunk-C4OYJOV4.js} +556 -101
  14. package/dist/chunk-C4OYJOV4.js.map +1 -0
  15. package/dist/{chunk-HGPQUCBC.js → chunk-FMTK4XNN.js} +3 -3
  16. package/dist/{chunk-U6WNSFX5.js → chunk-HEHYGYOX.js} +279 -44
  17. package/dist/chunk-HEHYGYOX.js.map +1 -0
  18. package/dist/{chunk-XAUHJD3L.js → chunk-K2JGDXGU.js} +2 -2
  19. package/dist/{chunk-HDCUMOOI.js → chunk-LBBUPSSC.js} +792 -559
  20. package/dist/chunk-LBBUPSSC.js.map +1 -0
  21. package/dist/{chunk-UQWSHFVX.js → chunk-SAUPYVLF.js} +1 -1
  22. package/dist/{chunk-UQWSHFVX.js.map → chunk-SAUPYVLF.js.map} +1 -1
  23. package/dist/{chunk-GRIQLQ52.js → chunk-T6ZJVI3A.js} +27 -23
  24. package/dist/chunk-T6ZJVI3A.js.map +1 -0
  25. package/dist/{chunk-DAGICKHT.js → chunk-ULX5FYEM.js} +3 -3
  26. package/dist/{chunk-FXFJRTKI.js → chunk-WK2Y6TGA.js} +3 -3
  27. package/dist/chunk-WK2Y6TGA.js.map +1 -0
  28. package/dist/chunk-YHCN776L.js +447 -0
  29. package/dist/chunk-YHCN776L.js.map +1 -0
  30. package/dist/components.d.ts +4 -4
  31. package/dist/components.js +12 -10
  32. package/dist/components.js.map +1 -1
  33. package/dist/{file-reference-PRTSLxKx.d.ts → file-reference-D037xOFK.d.ts} +0 -1
  34. package/dist/hooks.d.ts +221 -6
  35. package/dist/hooks.js +146 -49
  36. package/dist/hooks.js.map +1 -1
  37. package/dist/index.d.ts +24 -9
  38. package/dist/index.js +62 -28
  39. package/dist/index.js.map +1 -1
  40. package/dist/providers.js +1 -1
  41. package/dist/rbac/index.d.ts +124 -7
  42. package/dist/rbac/index.js +27 -7
  43. package/dist/{types-DUyCRSTj.d.ts → types-Bwgl--Xo.d.ts} +162 -1
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.js +1 -1
  46. package/dist/{usePublicRouteParams-D71QLlg4.d.ts → usePublicRouteParams-CTDELQ7H.d.ts} +2 -2
  47. package/dist/utils.d.ts +213 -3
  48. package/dist/utils.js +22 -2
  49. package/dist/utils.js.map +1 -1
  50. package/docs/api/classes/ColumnFactory.md +1 -1
  51. package/docs/api/classes/ErrorBoundary.md +1 -1
  52. package/docs/api/classes/InvalidScopeError.md +1 -1
  53. package/docs/api/classes/Logger.md +1 -1
  54. package/docs/api/classes/MissingUserContextError.md +1 -1
  55. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  56. package/docs/api/classes/PermissionDeniedError.md +1 -1
  57. package/docs/api/classes/RBACAuditManager.md +21 -17
  58. package/docs/api/classes/RBACCache.md +31 -23
  59. package/docs/api/classes/RBACEngine.md +5 -5
  60. package/docs/api/classes/RBACError.md +1 -1
  61. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  62. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  63. package/docs/api/classes/StorageUtils.md +1 -1
  64. package/docs/api/enums/FileCategory.md +1 -1
  65. package/docs/api/enums/LogLevel.md +1 -1
  66. package/docs/api/enums/RBACErrorCode.md +1 -1
  67. package/docs/api/enums/RPCFunction.md +1 -1
  68. package/docs/api/interfaces/AddressFieldProps.md +241 -0
  69. package/docs/api/interfaces/AddressFieldRef.md +94 -0
  70. package/docs/api/interfaces/AggregateConfig.md +1 -1
  71. package/docs/api/interfaces/AutocompleteOptions.md +75 -0
  72. package/docs/api/interfaces/BadgeProps.md +1 -1
  73. package/docs/api/interfaces/ButtonProps.md +1 -1
  74. package/docs/api/interfaces/CalendarProps.md +1 -1
  75. package/docs/api/interfaces/CardProps.md +1 -1
  76. package/docs/api/interfaces/ColorPalette.md +1 -1
  77. package/docs/api/interfaces/ColorShade.md +1 -1
  78. package/docs/api/interfaces/ComplianceResult.md +1 -1
  79. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  80. package/docs/api/interfaces/DataRecord.md +1 -1
  81. package/docs/api/interfaces/DataTableAction.md +1 -1
  82. package/docs/api/interfaces/DataTableColumn.md +1 -1
  83. package/docs/api/interfaces/DataTableProps.md +1 -1
  84. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  85. package/docs/api/interfaces/DatabaseComplianceResult.md +1 -1
  86. package/docs/api/interfaces/DatabaseIssue.md +1 -1
  87. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  88. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  89. package/docs/api/interfaces/EventAppRoleData.md +1 -1
  90. package/docs/api/interfaces/ExportColumn.md +1 -1
  91. package/docs/api/interfaces/ExportOptions.md +1 -1
  92. package/docs/api/interfaces/FileDisplayProps.md +15 -15
  93. package/docs/api/interfaces/FileMetadata.md +1 -1
  94. package/docs/api/interfaces/FileReference.md +1 -1
  95. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  96. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  97. package/docs/api/interfaces/FileUploadProps.md +1 -1
  98. package/docs/api/interfaces/FooterProps.md +1 -1
  99. package/docs/api/interfaces/FormFieldProps.md +1 -1
  100. package/docs/api/interfaces/FormProps.md +1 -1
  101. package/docs/api/interfaces/GrantEventAppRoleParams.md +1 -1
  102. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  103. package/docs/api/interfaces/InputProps.md +1 -1
  104. package/docs/api/interfaces/LabelProps.md +1 -1
  105. package/docs/api/interfaces/LoggerConfig.md +1 -1
  106. package/docs/api/interfaces/LoginFormProps.md +1 -1
  107. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  108. package/docs/api/interfaces/NavigationContextType.md +1 -1
  109. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  110. package/docs/api/interfaces/NavigationItem.md +1 -1
  111. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  112. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  113. package/docs/api/interfaces/Organisation.md +1 -1
  114. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  115. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  116. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  117. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  118. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  119. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  120. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  121. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  122. package/docs/api/interfaces/PagePermissionGuardProps.md +11 -11
  123. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  124. package/docs/api/interfaces/PaletteData.md +1 -1
  125. package/docs/api/interfaces/ParsedAddress.md +120 -0
  126. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  127. package/docs/api/interfaces/ProgressProps.md +1 -1
  128. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  129. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  130. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  131. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  132. package/docs/api/interfaces/QuickFix.md +1 -1
  133. package/docs/api/interfaces/RBACAccessValidateParams.md +1 -1
  134. package/docs/api/interfaces/RBACAccessValidateResult.md +1 -1
  135. package/docs/api/interfaces/RBACAuditLogParams.md +1 -1
  136. package/docs/api/interfaces/RBACAuditLogResult.md +1 -1
  137. package/docs/api/interfaces/RBACConfig.md +26 -3
  138. package/docs/api/interfaces/RBACContext.md +1 -1
  139. package/docs/api/interfaces/RBACLogger.md +5 -5
  140. package/docs/api/interfaces/RBACPageAccessCheckParams.md +1 -1
  141. package/docs/api/interfaces/RBACPerformanceMetrics.md +138 -0
  142. package/docs/api/interfaces/RBACPermissionCheckParams.md +1 -1
  143. package/docs/api/interfaces/RBACPermissionCheckResult.md +1 -1
  144. package/docs/api/interfaces/RBACPermissionsGetParams.md +1 -1
  145. package/docs/api/interfaces/RBACPermissionsGetResult.md +1 -1
  146. package/docs/api/interfaces/RBACResult.md +1 -1
  147. package/docs/api/interfaces/RBACRoleGrantParams.md +1 -1
  148. package/docs/api/interfaces/RBACRoleGrantResult.md +1 -1
  149. package/docs/api/interfaces/RBACRoleRevokeParams.md +1 -1
  150. package/docs/api/interfaces/RBACRoleRevokeResult.md +1 -1
  151. package/docs/api/interfaces/RBACRoleValidateParams.md +1 -1
  152. package/docs/api/interfaces/RBACRoleValidateResult.md +1 -1
  153. package/docs/api/interfaces/RBACRolesListParams.md +1 -1
  154. package/docs/api/interfaces/RBACRolesListResult.md +1 -1
  155. package/docs/api/interfaces/RBACSessionTrackParams.md +1 -1
  156. package/docs/api/interfaces/RBACSessionTrackResult.md +1 -1
  157. package/docs/api/interfaces/ResourcePermissions.md +1 -1
  158. package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
  159. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  160. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  161. package/docs/api/interfaces/RoleManagementResult.md +1 -1
  162. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  163. package/docs/api/interfaces/RouteConfig.md +1 -1
  164. package/docs/api/interfaces/RuntimeComplianceResult.md +1 -1
  165. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  166. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  167. package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
  168. package/docs/api/interfaces/SetupIssue.md +1 -1
  169. package/docs/api/interfaces/StorageConfig.md +1 -1
  170. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  171. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  172. package/docs/api/interfaces/StorageListOptions.md +1 -1
  173. package/docs/api/interfaces/StorageListResult.md +1 -1
  174. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  175. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  176. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  177. package/docs/api/interfaces/StyleImport.md +1 -1
  178. package/docs/api/interfaces/SwitchProps.md +1 -1
  179. package/docs/api/interfaces/TabsContentProps.md +1 -1
  180. package/docs/api/interfaces/TabsListProps.md +1 -1
  181. package/docs/api/interfaces/TabsProps.md +1 -1
  182. package/docs/api/interfaces/TabsTriggerProps.md +1 -1
  183. package/docs/api/interfaces/TextareaProps.md +1 -1
  184. package/docs/api/interfaces/ToastActionElement.md +1 -1
  185. package/docs/api/interfaces/ToastProps.md +1 -1
  186. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  187. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  188. package/docs/api/interfaces/UseFormDialogOptions.md +1 -1
  189. package/docs/api/interfaces/UseFormDialogReturn.md +1 -1
  190. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  191. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  192. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  193. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  194. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  195. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  196. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  197. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  198. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  199. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  200. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  201. package/docs/api/interfaces/UseResourcePermissionsOptions.md +1 -1
  202. package/docs/api/interfaces/UserEventAccess.md +1 -1
  203. package/docs/api/interfaces/UserMenuProps.md +1 -1
  204. package/docs/api/interfaces/UserProfile.md +1 -1
  205. package/docs/api/modules.md +318 -59
  206. package/docs/best-practices/performance.md +11 -0
  207. package/docs/implementation-guides/file-upload-storage.md +29 -0
  208. package/docs/rbac/README.md +2 -1
  209. package/docs/rbac/api-reference.md +11 -0
  210. package/docs/rbac/performance.md +320 -0
  211. package/docs/standards/01-architecture-standard.md +5 -0
  212. package/docs/standards/05-security-standard.md +12 -0
  213. package/package.json +1 -1
  214. package/src/components/AddressField/AddressField.test.tsx +411 -0
  215. package/src/components/AddressField/AddressField.tsx +323 -0
  216. package/src/components/AddressField/README.md +336 -0
  217. package/src/components/AddressField/index.ts +10 -0
  218. package/src/components/AddressField/types.ts +65 -0
  219. package/src/components/FileDisplay/FileDisplay.test.tsx +454 -0
  220. package/src/components/FileDisplay/FileDisplay.tsx +28 -1
  221. package/src/components/index.ts +2 -0
  222. package/src/hooks/__tests__/useFileDisplay.unit.test.ts +30 -5
  223. package/src/hooks/__tests__/useOrganisationSecurity.unit.test.tsx +11 -10
  224. package/src/hooks/__tests__/usePublicFileDisplay.test.ts +31 -6
  225. package/src/hooks/index.ts +6 -0
  226. package/src/hooks/public/usePublicFileDisplay.ts +8 -10
  227. package/src/hooks/useAddressAutocomplete.test.ts +318 -0
  228. package/src/hooks/useAddressAutocomplete.ts +268 -0
  229. package/src/hooks/useFileDisplay.ts +3 -15
  230. package/src/hooks/useFileReference.test.ts +20 -3
  231. package/src/hooks/useFileReference.ts +3 -24
  232. package/src/hooks/useFileUrlCache.ts +246 -0
  233. package/src/hooks/useInactivityTracker.ts +31 -20
  234. package/src/hooks/useOrganisationSecurity.test.ts +10 -7
  235. package/src/hooks/useOrganisationSecurity.ts +3 -3
  236. package/src/hooks/useQueryCache.ts +315 -0
  237. package/src/index.ts +2 -0
  238. package/src/providers/services/EventServiceProvider.tsx +4 -1
  239. package/src/rbac/api.test.ts +21 -6
  240. package/src/rbac/api.ts +32 -11
  241. package/src/rbac/audit-batched.ts +223 -0
  242. package/src/rbac/audit-enhanced.ts +2 -2
  243. package/src/rbac/audit.test.ts +6 -5
  244. package/src/rbac/audit.ts +34 -6
  245. package/src/rbac/cache-invalidation.ts +63 -12
  246. package/src/rbac/cache.test.ts +2 -2
  247. package/src/rbac/cache.ts +61 -14
  248. package/src/rbac/components/PagePermissionGuard.tsx +19 -10
  249. package/src/rbac/components/__tests__/PagePermissionGuard.performance.test.tsx +248 -0
  250. package/src/rbac/config.ts +9 -0
  251. package/src/rbac/engine.ts +2 -21
  252. package/src/rbac/hooks/usePermissions.ts +21 -5
  253. package/src/rbac/index.ts +19 -0
  254. package/src/rbac/performance.ts +210 -0
  255. package/src/rbac/request-deduplication.ts +87 -0
  256. package/src/rbac/utils/deep-equal.ts +93 -0
  257. package/src/types/file-reference.ts +0 -1
  258. package/src/utils/file-reference/__tests__/file-reference.test.ts +31 -4
  259. package/src/utils/file-reference/index.ts +44 -15
  260. package/src/utils/google-places/googlePlacesUtils.test.ts +403 -0
  261. package/src/utils/google-places/googlePlacesUtils.ts +475 -0
  262. package/src/utils/google-places/index.ts +26 -0
  263. package/src/utils/google-places/loadGoogleMapsScript.ts +207 -0
  264. package/src/utils/google-places/types.ts +94 -0
  265. package/src/utils/index.ts +23 -0
  266. package/src/utils/request-deduplication.ts +165 -0
  267. package/src/utils/storage/helpers.ts +143 -4
  268. package/dist/chunk-445GEP27.js.map +0 -1
  269. package/dist/chunk-FMUCXFII.js +0 -76
  270. package/dist/chunk-FMUCXFII.js.map +0 -1
  271. package/dist/chunk-FSFQFJCU.js.map +0 -1
  272. package/dist/chunk-FXFJRTKI.js.map +0 -1
  273. package/dist/chunk-GRIQLQ52.js.map +0 -1
  274. package/dist/chunk-HDCUMOOI.js.map +0 -1
  275. package/dist/chunk-OALXJH4Y.js.map +0 -1
  276. package/dist/chunk-TC7D3CR3.js.map +0 -1
  277. package/dist/chunk-U6WNSFX5.js.map +0 -1
  278. /package/dist/{DataTable-IX2NBUTP.js.map → DataTable-K3RJRSOX.js.map} +0 -0
  279. /package/dist/{UnifiedAuthProvider-A4BCQRJY.js.map → UnifiedAuthProvider-B76OWOAT.js.map} +0 -0
  280. /package/dist/{api-BMFCXVQX.js.map → api-YP7XD5L6.js.map} +0 -0
  281. /package/dist/{audit-WRS3KJKI.js.map → audit-B5P6FFIR.js.map} +0 -0
  282. /package/dist/{chunk-HGPQUCBC.js.map → chunk-FMTK4XNN.js.map} +0 -0
  283. /package/dist/{chunk-XAUHJD3L.js.map → chunk-K2JGDXGU.js.map} +0 -0
  284. /package/dist/{chunk-DAGICKHT.js.map → chunk-ULX5FYEM.js.map} +0 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @file Address Autocomplete Hook
3
+ * @package @jmruthers/pace-core
4
+ * @module Hooks/AddressAutocomplete
5
+ * @since 0.1.0
6
+ *
7
+ * Hook for managing Google Places API autocomplete functionality.
8
+ * Provides debounced input, caching, and address selection.
9
+ */
10
+
11
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
12
+ import { useDebounce } from './useDebounce';
13
+ import { useQueryCache } from './useQueryCache';
14
+ import {
15
+ fetchPlaceAutocomplete,
16
+ fetchPlaceDetails,
17
+ createAddressFromPlaceResult,
18
+ getAddressByPlaceId,
19
+ } from '../utils/google-places';
20
+ import type {
21
+ GooglePlaceAutocompletePrediction,
22
+ ParsedAddress,
23
+ AutocompleteOptions,
24
+ } from '../utils/google-places';
25
+
26
+ export interface UseAddressAutocompleteOptions {
27
+ /** Google Places API options */
28
+ autocompleteOptions?: AutocompleteOptions;
29
+ /** Debounce delay in milliseconds (default: 300) */
30
+ debounceDelay?: number;
31
+ /** Enable caching (default: true) */
32
+ cacheEnabled?: boolean;
33
+ /** Cache TTL configuration */
34
+ cacheTTL?: {
35
+ /** Autocomplete cache TTL in seconds (default: 3600 = 1 hour) */
36
+ autocomplete?: number;
37
+ /** Place details cache TTL in seconds (default: 86400 = 24 hours) */
38
+ placeDetails?: number;
39
+ };
40
+ }
41
+
42
+ export interface UseAddressAutocompleteReturn {
43
+ /** Array of autocomplete suggestions */
44
+ suggestions: GooglePlaceAutocompletePrediction[];
45
+ /** Loading state */
46
+ isLoading: boolean;
47
+ /** Error state */
48
+ error: Error | null;
49
+ /** Select an address by place_id */
50
+ selectAddress: (placeId: string) => Promise<ParsedAddress | null>;
51
+ /** Get address by place_id (uses cache) */
52
+ getAddressByPlaceId: (placeId: string) => Promise<ParsedAddress | null>;
53
+ /** Clear suggestions */
54
+ clearSuggestions: () => void;
55
+ }
56
+
57
+ /**
58
+ * Hook for Google Places API autocomplete with caching
59
+ *
60
+ * @param apiKey - Google Places API key
61
+ * @param inputValue - Current input value
62
+ * @param options - Hook configuration options
63
+ * @returns Autocomplete state and functions
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * const { suggestions, isLoading, selectAddress } = useAddressAutocomplete(
68
+ * apiKey,
69
+ * inputValue,
70
+ * { debounceDelay: 300, cacheEnabled: true }
71
+ * );
72
+ * ```
73
+ */
74
+ export function useAddressAutocomplete(
75
+ apiKey: string,
76
+ inputValue: string,
77
+ options: UseAddressAutocompleteOptions = {}
78
+ ): UseAddressAutocompleteReturn {
79
+ const {
80
+ debounceDelay = 300,
81
+ cacheEnabled = true,
82
+ cacheTTL = {
83
+ autocomplete: 3600, // 1 hour
84
+ placeDetails: 86400, // 24 hours
85
+ },
86
+ autocompleteOptions,
87
+ } = options;
88
+
89
+ const [suggestions, setSuggestions] = useState<GooglePlaceAutocompletePrediction[]>([]);
90
+ const [isLoading, setIsLoading] = useState(false);
91
+ const [error, setError] = useState<Error | null>(null);
92
+
93
+ const debouncedInput = useDebounce(inputValue, debounceDelay);
94
+ const { getCachedQuery } = useQueryCache();
95
+ const abortControllerRef = useRef<AbortController | null>(null);
96
+
97
+ // Memoize autocompleteOptions to prevent unnecessary re-renders
98
+ const memoizedAutocompleteOptions = useMemo(
99
+ () => autocompleteOptions,
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ [JSON.stringify(autocompleteOptions)]
102
+ );
103
+
104
+ // Fetch autocomplete suggestions
105
+ useEffect(() => {
106
+ // Cancel previous request if still in flight
107
+ if (abortControllerRef.current) {
108
+ abortControllerRef.current.abort();
109
+ }
110
+
111
+ // Reset state if input is empty
112
+ if (!debouncedInput.trim()) {
113
+ setSuggestions([]);
114
+ setIsLoading(false);
115
+ setError(null);
116
+ return;
117
+ }
118
+
119
+ // Don't fetch if no API key
120
+ if (!apiKey) {
121
+ setError(new Error('Google Places API key is required'));
122
+ return;
123
+ }
124
+
125
+ setIsLoading(true);
126
+ setError(null);
127
+
128
+ const fetchSuggestions = async () => {
129
+ try {
130
+ let predictions: GooglePlaceAutocompletePrediction[];
131
+
132
+ if (cacheEnabled) {
133
+ // Use cached query with TTL
134
+ predictions = await getCachedQuery(
135
+ 'google-places-autocomplete',
136
+ 'query',
137
+ debouncedInput,
138
+ async () => {
139
+ return fetchPlaceAutocomplete(debouncedInput, apiKey, memoizedAutocompleteOptions);
140
+ },
141
+ { ttl: cacheTTL.autocomplete, enabled: true }
142
+ );
143
+ } else {
144
+ // Direct fetch without cache
145
+ predictions = await fetchPlaceAutocomplete(debouncedInput, apiKey, memoizedAutocompleteOptions);
146
+ }
147
+
148
+ setSuggestions(predictions);
149
+ setIsLoading(false);
150
+ } catch (err) {
151
+ // Ignore aborted requests
152
+ if (err instanceof Error && err.name === 'AbortError') {
153
+ return;
154
+ }
155
+
156
+ const error = err instanceof Error ? err : new Error('Failed to fetch autocomplete suggestions');
157
+ setError(error);
158
+ setSuggestions([]);
159
+ setIsLoading(false);
160
+ }
161
+ };
162
+
163
+ fetchSuggestions();
164
+
165
+ // Cleanup function to cancel in-flight requests
166
+ return () => {
167
+ if (abortControllerRef.current) {
168
+ abortControllerRef.current.abort();
169
+ }
170
+ };
171
+ // eslint-disable-next-line react-hooks/exhaustive-deps
172
+ }, [debouncedInput, apiKey, cacheEnabled, cacheTTL.autocomplete]);
173
+
174
+ // Select address by place_id
175
+ const selectAddress = useCallback(
176
+ async (placeId: string): Promise<ParsedAddress | null> => {
177
+ if (!placeId || !apiKey) {
178
+ return null;
179
+ }
180
+
181
+ setIsLoading(true);
182
+ setError(null);
183
+
184
+ try {
185
+ let placeDetails;
186
+
187
+ if (cacheEnabled) {
188
+ // Use cached query with TTL
189
+ placeDetails = await getCachedQuery(
190
+ 'google-places-details',
191
+ 'place_id',
192
+ placeId,
193
+ async () => {
194
+ return fetchPlaceDetails(placeId, apiKey);
195
+ },
196
+ { ttl: cacheTTL.placeDetails, enabled: true }
197
+ );
198
+ } else {
199
+ // Direct fetch without cache
200
+ placeDetails = await fetchPlaceDetails(placeId, apiKey);
201
+ }
202
+
203
+ const parsedAddress = createAddressFromPlaceResult(placeDetails);
204
+ setIsLoading(false);
205
+ return parsedAddress;
206
+ } catch (err) {
207
+ const error = err instanceof Error ? err : new Error('Failed to fetch place details');
208
+ setError(error);
209
+ setIsLoading(false);
210
+ return null;
211
+ }
212
+ },
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ [apiKey, cacheEnabled, cacheTTL.placeDetails]
215
+ );
216
+
217
+ // Get address by place_id (utility function)
218
+ const getAddressByPlaceIdFn = useCallback(
219
+ async (placeId: string): Promise<ParsedAddress | null> => {
220
+ if (!placeId || !apiKey) {
221
+ return null;
222
+ }
223
+
224
+ try {
225
+ if (cacheEnabled) {
226
+ // Use cached query with TTL
227
+ return await getCachedQuery(
228
+ 'google-places-details',
229
+ 'place_id',
230
+ placeId,
231
+ async () => {
232
+ const result = await getAddressByPlaceId(placeId, apiKey);
233
+ if (!result) {
234
+ throw new Error('Failed to fetch address');
235
+ }
236
+ return result;
237
+ },
238
+ { ttl: cacheTTL.placeDetails, enabled: true }
239
+ );
240
+ } else {
241
+ return await getAddressByPlaceId(placeId, apiKey);
242
+ }
243
+ } catch (err) {
244
+ const error = err instanceof Error ? err : new Error('Failed to get address by place_id');
245
+ setError(error);
246
+ return null;
247
+ }
248
+ },
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ [apiKey, cacheEnabled, cacheTTL.placeDetails]
251
+ );
252
+
253
+ // Clear suggestions
254
+ const clearSuggestions = useCallback(() => {
255
+ setSuggestions([]);
256
+ setError(null);
257
+ }, []);
258
+
259
+ return {
260
+ suggestions,
261
+ isLoading,
262
+ error,
263
+ selectAddress,
264
+ getAddressByPlaceId: getAddressByPlaceIdFn,
265
+ clearSuggestions,
266
+ };
267
+ }
268
+
@@ -37,7 +37,7 @@
37
37
  import { useState, useEffect, useCallback } from 'react';
38
38
  import type { SupabaseClient } from '@supabase/supabase-js';
39
39
  import { FileReference, FileCategory } from '../types/file-reference';
40
- import { getPublicUrl, getSignedUrl } from '../utils/storage/helpers';
40
+ import { getPublicUrl, getSignedUrl, generateFileUrlsBatch } from '../utils/storage/helpers';
41
41
  import { createFileReferenceService } from '../utils/file-reference';
42
42
  import { logger } from '../utils/core/logger';
43
43
 
@@ -279,24 +279,12 @@ export function useFileDisplay(
279
279
  logger.debug('useFileDisplay', 'Setting file URL:', url ? 'URL set' : 'URL is null');
280
280
  setFileUrl(url);
281
281
  } else {
282
- // Multiple files mode - generate URLs for all files
283
- const urlMap = new Map<string, string>();
284
- for (const fileRef of files) {
285
- let url: string | null = null;
286
- if (fileRef.is_public) {
287
- url = getPublicUrl(supabase, fileRef.file_path, true);
288
- } else {
289
- const signedUrlResult = await getSignedUrl(supabase, fileRef.file_path, {
282
+ // Multiple files mode - generate URLs for all files in batch
283
+ const urlMap = await generateFileUrlsBatch(supabase, files, {
290
284
  appName: 'pace-core',
291
285
  orgId: organisation_id,
292
286
  expiresIn: 3600
293
287
  });
294
- url = signedUrlResult?.url || null;
295
- }
296
- if (url) {
297
- urlMap.set(fileRef.id, url);
298
- }
299
- }
300
288
  setFileUrls(urlMap);
301
289
  setFileReference(null);
302
290
  setFileUrl(null);
@@ -18,6 +18,7 @@ let serviceMock: any;
18
18
  const mockUploadFileWithReference = vi.fn();
19
19
  const mockGetPublicUrl = vi.fn();
20
20
  const mockGetSignedUrl = vi.fn();
21
+ const mockGenerateFileUrlsBatch = vi.fn();
21
22
 
22
23
  // Setup mocks
23
24
  beforeEach(() => {
@@ -47,12 +48,25 @@ beforeEach(() => {
47
48
  url: 'https://example.com/signed-file.jpg',
48
49
  expiresAt: new Date(Date.now() + 3600 * 1000).toISOString()
49
50
  });
51
+ // Mock batch URL generation to return URLs for all files
52
+ mockGenerateFileUrlsBatch.mockImplementation(async (supabase, files) => {
53
+ const urlMap = new Map<string, string>();
54
+ for (const file of files) {
55
+ if (file.is_public) {
56
+ urlMap.set(file.id, `https://example.com/${file.file_path}`);
57
+ } else {
58
+ urlMap.set(file.id, `https://example.com/signed/${file.file_path}`);
59
+ }
60
+ }
61
+ return urlMap;
62
+ });
50
63
 
51
64
  // Apply mocks
52
65
  vi.spyOn(fileReferenceUtils, 'createFileReferenceService').mockImplementation(mockCreateFileReferenceService as any);
53
66
  vi.spyOn(fileReferenceUtils, 'uploadFileWithReference').mockImplementation(mockUploadFileWithReference as any);
54
67
  vi.spyOn(storageHelpers, 'getPublicUrl').mockImplementation(mockGetPublicUrl as any);
55
68
  vi.spyOn(storageHelpers, 'getSignedUrl').mockImplementation(mockGetSignedUrl as any);
69
+ vi.spyOn(storageHelpers, 'generateFileUrlsBatch').mockImplementation(mockGenerateFileUrlsBatch as any);
56
70
  });
57
71
 
58
72
  afterEach(() => {
@@ -505,16 +519,19 @@ describe('[hook] useFilesByCategory', () => {
505
519
  );
506
520
 
507
521
  await waitFor(() => {
508
- expect(mockGetPublicUrl).toHaveBeenCalledWith(mockSupabase, publicFile.file_path, true);
509
- expect(mockGetSignedUrl).toHaveBeenCalledWith(
522
+ // Verify batch URL generation was called with both files
523
+ expect(mockGenerateFileUrlsBatch).toHaveBeenCalledWith(
510
524
  mockSupabase,
511
- privateFile.file_path,
525
+ [publicFile, privateFile],
512
526
  expect.objectContaining({
513
527
  appName: 'file-reference',
514
528
  orgId: 'test-org-123',
515
529
  expiresIn: 3600
516
530
  })
517
531
  );
532
+ // Verify URLs were generated
533
+ expect(result.current.fileUrls.get(publicFile.id)).toBeDefined();
534
+ expect(result.current.fileUrls.get(privateFile.id)).toBeDefined();
518
535
  });
519
536
  });
520
537
  });
@@ -11,7 +11,7 @@ import {
11
11
  FileCategory
12
12
  } from '../types/file-reference';
13
13
  import { createFileReferenceService, uploadFileWithReference } from '../utils/file-reference';
14
- import { getPublicUrl, getSignedUrl } from '../utils/storage/helpers';
14
+ import { getPublicUrl, getSignedUrl, generateFileUrlsBatch } from '../utils/storage/helpers';
15
15
  import { createLogger } from '../utils/core/logger';
16
16
 
17
17
  const log = createLogger('useFileReference');
@@ -401,33 +401,12 @@ export function useFilesByCategory(
401
401
  const files = await getFilesByCategory(table_name, record_id, category, organisation_id);
402
402
  setFileReferences(files);
403
403
 
404
- // Load URLs for all files
405
- const urlMap = new Map<string, string>();
406
-
407
- for (const fileRef of files) {
408
- try {
409
- let url: string | null = null;
410
-
411
- if (fileRef.is_public) {
412
- // Public files: generate public URL
413
- url = getPublicUrl(supabase, fileRef.file_path, true);
414
- } else {
415
- // Private files: generate signed URL
416
- const signedUrlResult = await getSignedUrl(supabase, fileRef.file_path, {
404
+ // Load URLs for all files in batch
405
+ const urlMap = await generateFileUrlsBatch(supabase, files, {
417
406
  appName: 'file-reference',
418
407
  orgId: organisation_id,
419
408
  expiresIn: 3600
420
409
  });
421
- url = signedUrlResult?.url || null;
422
- }
423
-
424
- if (url) {
425
- urlMap.set(fileRef.id, url);
426
- }
427
- } catch (err) {
428
- log.error(`Failed to load URL for file ${fileRef.id}:`, err);
429
- }
430
- }
431
410
 
432
411
  setFileUrls(urlMap);
433
412
  return files;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * @file File URL Cache Hook
3
+ * @package @jmruthers/pace-core
4
+ * @module Hooks
5
+ *
6
+ * Centralized caching hook for file URLs to prevent duplicate requests
7
+ * and improve performance across components.
8
+ *
9
+ * Features:
10
+ * - TTL-based caching matching signed URL expiration (3600s)
11
+ * - Automatic cache cleanup
12
+ * - Supports both public and signed URLs
13
+ * - Thread-safe cache operations
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * import { useFileUrlCache } from '@jmruthers/pace-core';
18
+ *
19
+ * function MyComponent() {
20
+ * const { getUrl, setUrl, clearCache } = useFileUrlCache();
21
+ *
22
+ * const url = await getUrl(fileReference, supabase, organisationId);
23
+ * return <img src={url} alt="File" />;
24
+ * }
25
+ * ```
26
+ */
27
+
28
+ import { useRef, useCallback } from 'react';
29
+ import type { SupabaseClient } from '@supabase/supabase-js';
30
+ import { FileReference } from '../types/file-reference';
31
+ import { getPublicUrl, getSignedUrl } from '../utils/storage/helpers';
32
+
33
+ interface CachedUrl {
34
+ url: string;
35
+ expiresAt: number; // Timestamp in milliseconds
36
+ }
37
+
38
+ // Global cache shared across all hook instances
39
+ const globalUrlCache = new Map<string, CachedUrl>();
40
+
41
+ // Cache size limit to prevent memory leaks
42
+ const MAX_CACHE_SIZE = 500;
43
+
44
+ // Default TTL matches signed URL expiration (3600 seconds = 1 hour)
45
+ const DEFAULT_TTL_MS = 3600 * 1000;
46
+
47
+ /**
48
+ * Generate cache key from file reference
49
+ */
50
+ function getCacheKey(fileReference: FileReference): string {
51
+ return `file-url:${fileReference.id}:${fileReference.is_public ? 'public' : 'private'}`;
52
+ }
53
+
54
+ /**
55
+ * Clean up expired entries and enforce size limit
56
+ */
57
+ function cleanupCache(): void {
58
+ const now = Date.now();
59
+
60
+ // Remove expired entries
61
+ for (const [key, value] of globalUrlCache.entries()) {
62
+ if (value.expiresAt < now) {
63
+ globalUrlCache.delete(key);
64
+ }
65
+ }
66
+
67
+ // Enforce size limit by removing oldest entries
68
+ if (globalUrlCache.size > MAX_CACHE_SIZE) {
69
+ const entries = Array.from(globalUrlCache.entries());
70
+ // Sort by expiration time (oldest first)
71
+ entries.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
72
+
73
+ // Remove oldest 20% of entries
74
+ const toRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
75
+ for (let i = 0; i < toRemove && i < entries.length; i++) {
76
+ globalUrlCache.delete(entries[i][0]);
77
+ }
78
+ }
79
+ }
80
+
81
+ export interface UseFileUrlCacheReturn {
82
+ /**
83
+ * Get URL for a file reference, using cache if available
84
+ * @param fileReference - File reference to get URL for
85
+ * @param supabase - Supabase client instance
86
+ * @param organisationId - Organisation ID for signed URLs
87
+ * @param ttl - Time to live in milliseconds (default: 3600000 = 1 hour)
88
+ * @returns Promise resolving to URL string or null
89
+ */
90
+ getUrl: (
91
+ fileReference: FileReference,
92
+ supabase: SupabaseClient,
93
+ organisationId: string,
94
+ ttl?: number
95
+ ) => Promise<string | null>;
96
+
97
+ /**
98
+ * Set URL in cache
99
+ * @param fileReference - File reference
100
+ * @param url - URL to cache
101
+ * @param ttl - Time to live in milliseconds (default: 3600000 = 1 hour)
102
+ */
103
+ setUrl: (fileReference: FileReference, url: string, ttl?: number) => void;
104
+
105
+ /**
106
+ * Get URL from cache without generating if missing
107
+ * @param fileReference - File reference
108
+ * @returns Cached URL or null if not in cache or expired
109
+ */
110
+ getCachedUrl: (fileReference: FileReference) => string | null;
111
+
112
+ /**
113
+ * Clear cache for a specific file reference
114
+ * @param fileReference - File reference to clear
115
+ */
116
+ clearFile: (fileReference: FileReference) => void;
117
+
118
+ /**
119
+ * Clear all cached URLs
120
+ */
121
+ clearCache: () => void;
122
+
123
+ /**
124
+ * Get cache statistics
125
+ */
126
+ getCacheStats: () => { size: number; maxSize: number };
127
+ }
128
+
129
+ /**
130
+ * Hook for centralized file URL caching
131
+ *
132
+ * This hook provides a shared cache for file URLs across all components,
133
+ * preventing duplicate requests for the same file.
134
+ *
135
+ * @returns Cache operations and utilities
136
+ */
137
+ export function useFileUrlCache(): UseFileUrlCacheReturn {
138
+ // Use ref to ensure stable reference across renders
139
+ const cleanupIntervalRef = useRef<number | null>(null);
140
+
141
+ // Set up periodic cleanup (every 5 minutes)
142
+ if (cleanupIntervalRef.current === null && typeof window !== 'undefined') {
143
+ cleanupIntervalRef.current = window.setInterval(() => {
144
+ cleanupCache();
145
+ }, 5 * 60 * 1000); // 5 minutes
146
+ }
147
+
148
+ const getUrl = useCallback(async (
149
+ fileReference: FileReference,
150
+ supabase: SupabaseClient,
151
+ organisationId: string,
152
+ ttl: number = DEFAULT_TTL_MS
153
+ ): Promise<string | null> => {
154
+ const cacheKey = getCacheKey(fileReference);
155
+ const cached = globalUrlCache.get(cacheKey);
156
+ const now = Date.now();
157
+
158
+ // Return cached URL if still valid
159
+ if (cached && cached.expiresAt > now) {
160
+ return cached.url;
161
+ }
162
+
163
+ // Generate new URL
164
+ let url: string | null = null;
165
+
166
+ try {
167
+ if (fileReference.is_public) {
168
+ // Public files: generate public URL (synchronous)
169
+ url = getPublicUrl(supabase, fileReference.file_path, true);
170
+ } else {
171
+ // Private files: generate signed URL (async)
172
+ const signedUrlResult = await getSignedUrl(supabase, fileReference.file_path, {
173
+ appName: 'pace-core',
174
+ orgId: organisationId,
175
+ expiresIn: Math.floor(ttl / 1000) // Convert ms to seconds
176
+ });
177
+ url = signedUrlResult?.url || null;
178
+ }
179
+
180
+ // Cache the URL if generated successfully
181
+ if (url) {
182
+ globalUrlCache.set(cacheKey, {
183
+ url,
184
+ expiresAt: now + ttl
185
+ });
186
+ cleanupCache(); // Clean up after adding
187
+ }
188
+
189
+ return url;
190
+ } catch (error) {
191
+ console.error('Failed to generate file URL:', error);
192
+ return null;
193
+ }
194
+ }, []);
195
+
196
+ const setUrl = useCallback((
197
+ fileReference: FileReference,
198
+ url: string,
199
+ ttl: number = DEFAULT_TTL_MS
200
+ ): void => {
201
+ const cacheKey = getCacheKey(fileReference);
202
+ globalUrlCache.set(cacheKey, {
203
+ url,
204
+ expiresAt: Date.now() + ttl
205
+ });
206
+ cleanupCache();
207
+ }, []);
208
+
209
+ const getCachedUrl = useCallback((fileReference: FileReference): string | null => {
210
+ const cacheKey = getCacheKey(fileReference);
211
+ const cached = globalUrlCache.get(cacheKey);
212
+ const now = Date.now();
213
+
214
+ if (cached && cached.expiresAt > now) {
215
+ return cached.url;
216
+ }
217
+
218
+ return null;
219
+ }, []);
220
+
221
+ const clearFile = useCallback((fileReference: FileReference): void => {
222
+ const cacheKey = getCacheKey(fileReference);
223
+ globalUrlCache.delete(cacheKey);
224
+ }, []);
225
+
226
+ const clearCache = useCallback((): void => {
227
+ globalUrlCache.clear();
228
+ }, []);
229
+
230
+ const getCacheStats = useCallback(() => {
231
+ return {
232
+ size: globalUrlCache.size,
233
+ maxSize: MAX_CACHE_SIZE
234
+ };
235
+ }, []);
236
+
237
+ return {
238
+ getUrl,
239
+ setUrl,
240
+ getCachedUrl,
241
+ clearFile,
242
+ clearCache,
243
+ getCacheStats
244
+ };
245
+ }
246
+