@startsimpli/api 0.1.0 → 0.2.1

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.
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback } from 'react'
4
+
5
+ export interface ServerDetailOptions {
6
+ /** Custom fetch function. Use authFetch from @startsimpli/auth for authenticated calls. */
7
+ fetcher?: typeof fetch
8
+ /** Pass null to skip fetching (e.g., when ID is not yet known) */
9
+ disabled?: boolean
10
+ }
11
+
12
+ export interface ServerDetailResult<T> {
13
+ data: T | null
14
+ loading: boolean
15
+ error: string | null
16
+ refresh: () => void
17
+ }
18
+
19
+ /**
20
+ * Hook for fetching a single resource.
21
+ *
22
+ * @example
23
+ * import { useServerDetail } from '@startsimpli/api'
24
+ * import { authFetch } from '@startsimpli/auth'
25
+ *
26
+ * const { data, loading, error } = useServerDetail<User>(
27
+ * '/api/v1/users/',
28
+ * userId,
29
+ * { fetcher: authFetch }
30
+ * )
31
+ */
32
+ export function useServerDetail<T>(
33
+ endpoint: string,
34
+ id?: string | number | null,
35
+ options: ServerDetailOptions = {}
36
+ ): ServerDetailResult<T> {
37
+ const { fetcher = fetch, disabled = false } = options
38
+
39
+ const [data, setData] = useState<T | null>(null)
40
+ const [loading, setLoading] = useState(!disabled && id !== null && id !== undefined)
41
+ const [error, setError] = useState<string | null>(null)
42
+
43
+ const fetchData = useCallback(async () => {
44
+ if (disabled || id === null) return
45
+
46
+ const url =
47
+ id !== undefined
48
+ ? `${endpoint}${endpoint.endsWith('/') ? '' : '/'}${id}/`
49
+ : endpoint
50
+
51
+ setLoading(true)
52
+ setError(null)
53
+ try {
54
+ const response = await fetcher(url)
55
+ if (!response.ok) {
56
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`)
57
+ }
58
+ setData(await response.json())
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : 'Failed to load data')
61
+ } finally {
62
+ setLoading(false)
63
+ }
64
+ }, [endpoint, id, disabled, fetcher])
65
+
66
+ useEffect(() => {
67
+ fetchData()
68
+ }, [fetchData])
69
+
70
+ return { data, loading, error, refresh: fetchData }
71
+ }
@@ -0,0 +1,169 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react'
4
+
5
+ export interface ServerListOptions {
6
+ pageSize?: number
7
+ initialPage?: number
8
+ initialSearch?: string
9
+ initialSortField?: string
10
+ initialSortDir?: 'asc' | 'desc'
11
+ /** Extra static query params to append on every request */
12
+ params?: Record<string, string | number | boolean>
13
+ /** Disable auto-fetch on mount */
14
+ disabled?: boolean
15
+ /** Custom fetch function (defaults to global fetch). Use authFetch from @startsimpli/auth for authenticated calls. */
16
+ fetcher?: typeof fetch
17
+ }
18
+
19
+ export interface ServerListResult<T> {
20
+ data: T[]
21
+ total: number
22
+ loading: boolean
23
+ error: string | null
24
+ page: number
25
+ pageSize: number
26
+ search: string
27
+ sortField: string
28
+ sortDir: 'asc' | 'desc'
29
+ setPage: (page: number) => void
30
+ setPageSize: (size: number) => void
31
+ setSearch: (search: string) => void
32
+ setSort: (field: string, dir?: 'asc' | 'desc') => void
33
+ refresh: () => void
34
+ }
35
+
36
+ /**
37
+ * Hook for server-side paginated list fetching.
38
+ * Sends ?page=N&pageSize=Y&sortField=Z&sortDirection=asc|desc&search=Q
39
+ * to the provided endpoint.
40
+ *
41
+ * @example
42
+ * import { useServerList } from '@startsimpli/api'
43
+ * import { authFetch } from '@startsimpli/auth'
44
+ *
45
+ * const { data, total, loading, page, setPage, setSearch } = useServerList<Email>(
46
+ * '/api/v1/emails/',
47
+ * { fetcher: authFetch, pageSize: 25 }
48
+ * )
49
+ */
50
+ export function useServerList<T>(
51
+ endpoint: string,
52
+ options: ServerListOptions = {}
53
+ ): ServerListResult<T> {
54
+ const {
55
+ pageSize: initialPageSize = 25,
56
+ initialPage = 1,
57
+ initialSearch = '',
58
+ initialSortField = '',
59
+ initialSortDir = 'asc',
60
+ params: extraParams = {},
61
+ disabled = false,
62
+ fetcher = fetch,
63
+ } = options
64
+
65
+ const [page, setPage] = useState(initialPage)
66
+ const [pageSize, setPageSize] = useState(initialPageSize)
67
+ const [search, setSearchState] = useState(initialSearch)
68
+ const [sortField, setSortField] = useState(initialSortField)
69
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>(initialSortDir)
70
+ const [data, setData] = useState<T[]>([])
71
+ const [total, setTotal] = useState(0)
72
+ const [loading, setLoading] = useState(!disabled)
73
+ const [error, setError] = useState<string | null>(null)
74
+ const refreshCountRef = useRef(0)
75
+
76
+ const fetchData = useCallback(async () => {
77
+ if (disabled) return
78
+
79
+ setLoading(true)
80
+ setError(null)
81
+
82
+ const searchParams = new URLSearchParams()
83
+ searchParams.set('page', String(page))
84
+ searchParams.set('pageSize', String(pageSize))
85
+ if (sortField) {
86
+ searchParams.set('sortField', sortField)
87
+ searchParams.set('sortDirection', sortDir)
88
+ }
89
+ if (search) searchParams.set('search', search)
90
+ for (const [k, v] of Object.entries(extraParams)) {
91
+ searchParams.set(k, String(v))
92
+ }
93
+
94
+ const sep = endpoint.includes('?') ? '&' : '?'
95
+ const url = `${endpoint}${sep}${searchParams.toString()}`
96
+
97
+ try {
98
+ const response = await fetcher(url)
99
+ if (!response.ok) {
100
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`)
101
+ }
102
+ const json = await response.json()
103
+ // Support { results: T[], count: N } (DRF default), { data: T[], total: N }, or plain array
104
+ if (Array.isArray(json)) {
105
+ setData(json)
106
+ setTotal(json.length)
107
+ } else if (json.results !== undefined) {
108
+ setData(json.results)
109
+ setTotal(json.count ?? json.results.length)
110
+ } else if (json.data !== undefined) {
111
+ setData(json.data)
112
+ setTotal(json.total ?? json.data.length)
113
+ } else {
114
+ setData([])
115
+ setTotal(0)
116
+ }
117
+ } catch (err) {
118
+ setError(err instanceof Error ? err.message : 'Failed to load data')
119
+ setData([])
120
+ } finally {
121
+ setLoading(false)
122
+ }
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
124
+ }, [endpoint, page, pageSize, search, sortField, sortDir, disabled, fetcher, refreshCountRef.current])
125
+
126
+ useEffect(() => {
127
+ fetchData()
128
+ }, [fetchData])
129
+
130
+ const setSearch = useCallback((value: string) => {
131
+ setPage(1)
132
+ setSearchState(value)
133
+ }, [])
134
+
135
+ const setSort = useCallback(
136
+ (field: string, dir?: 'asc' | 'desc') => {
137
+ if (field === sortField && !dir) {
138
+ setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
139
+ } else {
140
+ setSortField(field)
141
+ setSortDir(dir ?? 'asc')
142
+ }
143
+ setPage(1)
144
+ },
145
+ [sortField]
146
+ )
147
+
148
+ const refresh = useCallback(() => {
149
+ refreshCountRef.current += 1
150
+ fetchData()
151
+ }, [fetchData])
152
+
153
+ return {
154
+ data,
155
+ total,
156
+ loading,
157
+ error,
158
+ page,
159
+ pageSize,
160
+ search,
161
+ sortField,
162
+ sortDir,
163
+ setPage,
164
+ setPageSize,
165
+ setSearch,
166
+ setSort,
167
+ refresh,
168
+ }
169
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ export { EntitiesApi } from './lib/entities-api';
16
16
  export { WorkflowsApi } from './lib/workflows-api';
17
17
  export { MessagesApi } from './lib/messages-api';
18
18
  export type { Message, MessageStatus as MessageApiStatus, MessageRecipient, MessagingChannel as MessagingChannelType } from './lib/messages-api';
19
+ export { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from './lib/funnels-api';
20
+ export type { FunnelPreviewResult, FunnelRunFilters, FunnelTemplate } from './lib/funnels-api';
19
21
 
20
22
  // Fetch wrapper
21
23
  export { FetchWrapper } from './lib/fetch-wrapper';
@@ -74,11 +76,40 @@ export * from './types';
74
76
  export { createRateLimiter, getClientIP } from './lib/rate-limit';
75
77
  export type { RateLimitOptions, RateLimitResult } from './lib/rate-limit';
76
78
 
79
+ // In-memory cache (server-side only)
80
+ export { CacheStore } from './lib/cache-store';
81
+ export type { CacheStoreConfig } from './lib/cache-store';
82
+ export { CacheManager, getCacheManager, resetCacheManager } from './lib/cache-manager';
83
+ export type { CacheManagerConfig } from './lib/cache-manager';
84
+
77
85
  // Utils
78
86
  export * from './utils';
79
87
 
80
88
  // Constants
81
89
  export { ENDPOINTS } from './constants/endpoints';
90
+ export {
91
+ COMPANY_SIZE_OPTIONS,
92
+ LIFECYCLE_STAGE_OPTIONS,
93
+ REVENUE_RANGE_OPTIONS,
94
+ ACTIVITY_TYPE_OPTIONS,
95
+ ACTIVITY_OUTCOME_OPTIONS,
96
+ LOSS_REASON_OPTIONS,
97
+ DEAL_STAGE_OPTIONS,
98
+ } from './constants/options';
99
+ export type {
100
+ SelectOption,
101
+ CompanySize,
102
+ LifecycleStage,
103
+ RevenueRange,
104
+ ActivityType,
105
+ ActivityOutcome,
106
+ LossReason,
107
+ DealStage,
108
+ } from './constants/options';
109
+
110
+ // React hooks for server-side data fetching
111
+ export { useServerList, useServerDetail } from './hooks';
112
+ export type { ServerListOptions, ServerListResult, ServerDetailResult } from './hooks';
82
113
 
83
114
  // Environment variable utilities
84
115
  export { getRequiredEnv, getOptionalEnv, validateEnvVars } from './lib/env';
@@ -89,13 +120,15 @@ import { OrganizationsApi } from './lib/organizations-api';
89
120
  import { EntitiesApi } from './lib/entities-api';
90
121
  import { WorkflowsApi } from './lib/workflows-api';
91
122
  import { MessagesApi } from './lib/messages-api';
123
+ import { FunnelsApi } from './lib/funnels-api';
92
124
 
93
125
  import type { ApiClientConfig } from './lib/api-client';
94
126
 
95
127
  /**
96
- * Create a complete API client with all endpoints
128
+ * Create a complete API client with all endpoints.
129
+ * All config fields are optional — baseUrl defaults to '' (relative URLs via Next.js proxy).
97
130
  */
98
- export function createStartSimpliApi(config: ApiClientConfig) {
131
+ export function createStartSimpliApi(config: ApiClientConfig = {}) {
99
132
  const client = createApiClient(config);
100
133
 
101
134
  return {
@@ -105,5 +138,6 @@ export function createStartSimpliApi(config: ApiClientConfig) {
105
138
  entities: new EntitiesApi(client),
106
139
  workflows: new WorkflowsApi(client),
107
140
  messages: new MessagesApi(client),
141
+ funnels: new FunnelsApi(client),
108
142
  };
109
143
  }
@@ -2,10 +2,11 @@
2
2
  * Main API client for Django REST API
3
3
  */
4
4
 
5
- import { FetchWrapper, type FetchWrapperConfig } from './fetch-wrapper';
5
+ import { FetchWrapper } from './fetch-wrapper';
6
+ import type { FetchOptions } from '../types';
6
7
 
7
8
  export interface ApiClientConfig {
8
- baseUrl: string;
9
+ baseUrl?: string;
9
10
  getToken?: () => Promise<string | null> | string | null;
10
11
  onUnauthorized?: () => void;
11
12
  onTokenRefresh?: () => Promise<string | null>;
@@ -16,7 +17,7 @@ export class ApiClient {
16
17
  public readonly baseUrl: string;
17
18
 
18
19
  constructor(config: ApiClientConfig) {
19
- this.baseUrl = config.baseUrl;
20
+ this.baseUrl = config.baseUrl ?? '';
20
21
 
21
22
  this.fetcher = new FetchWrapper({
22
23
  baseUrl: config.baseUrl,
@@ -41,42 +42,32 @@ export class ApiClient {
41
42
  * Update auth token getter
42
43
  */
43
44
  setTokenGetter(getToken: () => Promise<string | null> | string | null): void {
44
- this.fetcher = new FetchWrapper({
45
- baseUrl: this.baseUrl,
46
- getToken,
47
- onUnauthorized: this.fetcher['config'].onUnauthorized,
48
- defaultHeaders: this.fetcher['config'].defaultHeaders,
49
- });
45
+ this.fetcher = this.fetcher.reconfigure({ getToken });
50
46
  }
51
47
 
52
48
  /**
53
49
  * Update unauthorized handler
54
50
  */
55
51
  setUnauthorizedHandler(onUnauthorized: () => void): void {
56
- this.fetcher = new FetchWrapper({
57
- baseUrl: this.baseUrl,
58
- getToken: this.fetcher['config'].getToken,
59
- onUnauthorized,
60
- defaultHeaders: this.fetcher['config'].defaultHeaders,
61
- });
52
+ this.fetcher = this.fetcher.reconfigure({ onUnauthorized });
62
53
  }
63
54
 
64
55
  /**
65
56
  * Convenience HTTP methods that delegate to FetchWrapper
66
57
  */
67
- async get<T>(endpoint: string, options?: any): Promise<T> {
58
+ async get<T>(endpoint: string, options?: FetchOptions): Promise<T> {
68
59
  return this.fetcher.get<T>(endpoint, options);
69
60
  }
70
61
 
71
- async post<T, D = unknown>(endpoint: string, data?: D, options?: any): Promise<T> {
62
+ async post<T, D = unknown>(endpoint: string, data?: D, options?: FetchOptions): Promise<T> {
72
63
  return this.fetcher.post<T, D>(endpoint, data, options);
73
64
  }
74
65
 
75
- async patch<T, D = unknown>(endpoint: string, data?: D, options?: any): Promise<T> {
66
+ async patch<T, D = unknown>(endpoint: string, data?: D, options?: FetchOptions): Promise<T> {
76
67
  return this.fetcher.patch<T, D>(endpoint, data, options);
77
68
  }
78
69
 
79
- async delete<T>(endpoint: string, options?: any): Promise<T> {
70
+ async delete<T>(endpoint: string, options?: FetchOptions): Promise<T> {
80
71
  return this.fetcher.delete<T>(endpoint, options);
81
72
  }
82
73
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cache manager built on top of CacheStore.
3
+ *
4
+ * Provides a cache-aside pattern (getOrSet), pattern-based invalidation,
5
+ * and separate entity/query stores — ready for use in Next.js API routes
6
+ * and server-side data fetching.
7
+ */
8
+
9
+ import { CacheStore } from './cache-store'
10
+ import type { CacheStoreConfig } from './cache-store'
11
+
12
+ export interface CacheManagerConfig {
13
+ entity?: Partial<CacheStoreConfig>
14
+ query?: Partial<CacheStoreConfig>
15
+ }
16
+
17
+ const DEFAULT_CONFIG: CacheManagerConfig = {
18
+ entity: { maxSize: 500, defaultTTL: 5 * 60 * 1000 },
19
+ query: { maxSize: 200, defaultTTL: 60 * 1000 },
20
+ }
21
+
22
+ export class CacheManager {
23
+ readonly entity: CacheStore
24
+ readonly query: CacheStore
25
+ private enabled: boolean
26
+
27
+ constructor(enabled = true, config: CacheManagerConfig = {}) {
28
+ this.entity = new CacheStore({ ...DEFAULT_CONFIG.entity, ...config.entity })
29
+ this.query = new CacheStore({ ...DEFAULT_CONFIG.query, ...config.query })
30
+ this.enabled = enabled
31
+ }
32
+
33
+ /**
34
+ * Cache-aside: return the cached value if present, otherwise call fetchFn,
35
+ * store the result, and return it.
36
+ */
37
+ async getOrSet<T>(key: string, ttlMs: number, fetchFn: () => Promise<T>): Promise<T> {
38
+ if (this.enabled) {
39
+ const cached = this.query.get(key) as T | undefined
40
+ if (cached !== undefined) return cached
41
+ }
42
+ const value = await fetchFn()
43
+ if (this.enabled) this.query.set(key, value, ttlMs)
44
+ return value
45
+ }
46
+
47
+ /** Invalidate a specific key in the query cache */
48
+ invalidateKey(key: string): void {
49
+ this.query.delete(key)
50
+ }
51
+
52
+ /** Invalidate all query cache keys matching a string prefix or RegExp */
53
+ invalidatePattern(pattern: string | RegExp): void {
54
+ this.query.deletePattern(pattern)
55
+ }
56
+
57
+ /** Invalidate all entity and query cache entries for the given prefix */
58
+ invalidateAll(prefix: string): void {
59
+ this.entity.deletePattern(prefix)
60
+ this.query.deletePattern(prefix)
61
+ }
62
+
63
+ setEnabled(enabled: boolean): void {
64
+ this.enabled = enabled
65
+ }
66
+
67
+ isEnabled(): boolean {
68
+ return this.enabled
69
+ }
70
+
71
+ /** Remove expired entries from both stores */
72
+ maintenance(): { entityRemoved: number; queryRemoved: number } {
73
+ return {
74
+ entityRemoved: this.entity.cleanup(),
75
+ queryRemoved: this.query.cleanup(),
76
+ }
77
+ }
78
+
79
+ clear(): void {
80
+ this.entity.clear()
81
+ this.query.clear()
82
+ }
83
+
84
+ getStats(): {
85
+ entity: ReturnType<CacheStore['getStats']>
86
+ query: ReturnType<CacheStore['getStats']>
87
+ } {
88
+ return { entity: this.entity.getStats(), query: this.query.getStats() }
89
+ }
90
+ }
91
+
92
+ // Singleton factory
93
+ let _cacheManager: CacheManager | null = null
94
+
95
+ export function getCacheManager(config?: CacheManagerConfig): CacheManager {
96
+ if (!_cacheManager) _cacheManager = new CacheManager(true, config)
97
+ return _cacheManager
98
+ }
99
+
100
+ export function resetCacheManager(): void {
101
+ _cacheManager?.clear()
102
+ _cacheManager = null
103
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * In-memory LRU cache store with TTL expiry.
3
+ *
4
+ * A minimal, zero-dependency cache for server-side caching in Next.js apps.
5
+ * State is in-memory and resets on server restart — not suitable for
6
+ * multi-instance production deployments without a shared backend (e.g. Redis).
7
+ */
8
+
9
+ interface CacheEntry<T> {
10
+ value: T
11
+ expiresAt: number
12
+ accessOrder: number
13
+ }
14
+
15
+ export interface CacheStoreConfig {
16
+ /** Maximum number of entries before LRU eviction (default: 1000) */
17
+ maxSize: number
18
+ /** Default TTL in milliseconds (default: 60 seconds) */
19
+ defaultTTL: number
20
+ }
21
+
22
+ const DEFAULT_CONFIG: CacheStoreConfig = {
23
+ maxSize: 1000,
24
+ defaultTTL: 60 * 1000,
25
+ }
26
+
27
+ export class CacheStore<T = unknown> {
28
+ private cache = new Map<string, CacheEntry<T>>()
29
+ private config: CacheStoreConfig
30
+ private hits = 0
31
+ private misses = 0
32
+ private accessCounter = 0
33
+
34
+ constructor(config: Partial<CacheStoreConfig> = {}) {
35
+ this.config = { ...DEFAULT_CONFIG, ...config }
36
+ }
37
+
38
+ get(key: string): T | undefined {
39
+ const entry = this.cache.get(key)
40
+ if (!entry) { this.misses++; return undefined }
41
+ if (Date.now() > entry.expiresAt) { this.cache.delete(key); this.misses++; return undefined }
42
+ entry.accessOrder = ++this.accessCounter
43
+ this.hits++
44
+ return entry.value
45
+ }
46
+
47
+ set(key: string, value: T, ttl?: number): void {
48
+ if (this.cache.size >= this.config.maxSize) this.evictLRU()
49
+ this.cache.set(key, {
50
+ value,
51
+ expiresAt: Date.now() + (ttl ?? this.config.defaultTTL),
52
+ accessOrder: ++this.accessCounter,
53
+ })
54
+ }
55
+
56
+ has(key: string): boolean {
57
+ const entry = this.cache.get(key)
58
+ if (!entry) return false
59
+ if (Date.now() > entry.expiresAt) { this.cache.delete(key); return false }
60
+ return true
61
+ }
62
+
63
+ delete(key: string): boolean {
64
+ return this.cache.delete(key)
65
+ }
66
+
67
+ /** Delete all keys matching a string prefix or RegExp pattern */
68
+ deletePattern(pattern: string | RegExp): number {
69
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern
70
+ let deleted = 0
71
+ for (const key of this.cache.keys()) {
72
+ if (regex.test(key)) { this.cache.delete(key); deleted++ }
73
+ }
74
+ return deleted
75
+ }
76
+
77
+ clear(): void {
78
+ this.cache.clear()
79
+ this.hits = 0
80
+ this.misses = 0
81
+ this.accessCounter = 0
82
+ }
83
+
84
+ /** Remove expired entries and return count removed */
85
+ cleanup(): number {
86
+ const now = Date.now()
87
+ let removed = 0
88
+ for (const [key, entry] of this.cache.entries()) {
89
+ if (now > entry.expiresAt) { this.cache.delete(key); removed++ }
90
+ }
91
+ return removed
92
+ }
93
+
94
+ getStats(): { size: number; maxSize: number; hits: number; misses: number; hitRate: number } {
95
+ const total = this.hits + this.misses
96
+ return {
97
+ size: this.cache.size,
98
+ maxSize: this.config.maxSize,
99
+ hits: this.hits,
100
+ misses: this.misses,
101
+ hitRate: total > 0 ? this.hits / total : 0,
102
+ }
103
+ }
104
+
105
+ private evictLRU(): void {
106
+ let oldestKey: string | null = null
107
+ let oldestOrder = Infinity
108
+ for (const [key, entry] of this.cache.entries()) {
109
+ if (entry.accessOrder < oldestOrder) { oldestOrder = entry.accessOrder; oldestKey = key }
110
+ }
111
+ if (oldestKey) this.cache.delete(oldestKey)
112
+ }
113
+ }
@@ -34,7 +34,7 @@ export class ApiException extends Error {
34
34
  * Parse Django REST Framework error response
35
35
  */
36
36
  export async function parseErrorResponse(response: Response): Promise<DRFApiError> {
37
- const contentType = response.headers.get('content-type');
37
+ const contentType = response.headers?.get('content-type');
38
38
 
39
39
  // Try to parse JSON error
40
40
  if (contentType?.includes('application/json')) {