Skip to content

Commit cb0a74b

Browse files
committed
fix(people): refresh published charts and isolate sync retries
1 parent 788aef7 commit cb0a74b

18 files changed

Lines changed: 211 additions & 46 deletions

File tree

‎apps/docs/content/docs/search/people.mdx.draft‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ People keeps the last published chart visible until the full replacement passes
4848

4949
SCIM user changes queue a refresh for the next scheduler pass, including changes that arrive while a refresh is running. Sim reads locally provisioned profiles together and publishes their reporting lines in one update. When SCIM deactivates or removes someone, their directory-managed profile is hidden immediately.
5050

51-
After a failure, check the status for the cause. Correct invalid or duplicate email addresses and reporting loops in the source. Check credentials and directory permissions for an access failure, then select **Retry sync** when available.
51+
After a failure, check the status for the cause. Correct invalid or duplicate email addresses and reporting loops in the source. Check credentials and directory permissions for an access failure. The status shows when Sim will retry automatically. After correcting a source error you can select **Retry sync** immediately. For a provider cooldown, the button becomes available when the cooldown ends.
5252

5353
To stop syncing, select **Disconnect**. This removes directory-only profiles and source-managed details. Existing Sim members and their manual profile data remain. Disconnect the current source before changing providers or tenants.
5454

‎apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ interface SyncStatusProps {
4949
sync: PeopleSyncStatus
5050
}
5151
function SyncStatus({ sync }: SyncStatusProps) {
52-
const { label, variant } = sync.retryAt
53-
? { label: 'Waiting to retry', variant: 'amber' as const }
54-
: sync.progress && sync.state === 'queued'
55-
? { label: 'Refresh in progress', variant: 'blue' as const }
56-
: STATES[sync.state]
52+
const { label, variant } =
53+
sync.retryBlocked && sync.state !== 'syncing'
54+
? { label: 'Waiting to retry', variant: 'amber' as const }
55+
: sync.progress && sync.state === 'queued'
56+
? { label: 'Refresh in progress', variant: 'blue' as const }
57+
: STATES[sync.state]
5758
const { progress } = sync
5859
const dates = [
5960
['Last attempted', sync.lastAttemptAt],
@@ -155,7 +156,7 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
155156
)
156157
const title = 'Directory sync'
157158
const syncing = sync?.state === 'syncing' || actions.pending
158-
const waitingToRetry = Boolean(sync?.retryAt)
159+
const waitingToRetry = Boolean(sync?.retryBlocked)
159160
return (
160161
<ChipModal open onOpenChange={(open) => !open && onClose()} srTitle={title}>
161162
<ChipModalHeader onClose={onClose}>{title}</ChipModalHeader>

‎apps/sim/app/o/[organizationId]/people/people-directory.tsx‎

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useQueryStates } from 'nuqs'
88
import { EmptyState } from '@/components/empty-state/empty-state'
99
import type { PersonSummary } from '@/lib/api/contracts/people'
1010
import { organizationRoutes } from '@/lib/navigation/paths'
11+
import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
1112
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
1213
import { PEOPLE_COLUMNS, PEOPLE_SEARCH_PLACEHOLDER } from '@/app/o/[organizationId]/people/columns'
1314
import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status'
@@ -68,12 +69,7 @@ export function PeopleDirectory({ organizationId }: PeopleProps) {
6869
const [departmentSearch, setDepartmentSearch] = useState('')
6970
const [departmentsOpen, setDepartmentsOpen] = useState(false)
7071
const departmentTerm = useSearchFilterValue(departmentSearch, SEARCH_DEBOUNCE_MS).trim()
71-
const searchTooLong = departmentSearch.trim().length > 200
72-
const departments = useDepartments(
73-
organizationId,
74-
departmentTerm,
75-
departmentsOpen && !searchTooLong && departmentTerm.length <= 200
76-
)
72+
const departments = useDepartments(organizationId, departmentTerm, departmentsOpen)
7773
const departmentWaiting = departmentSearch.trim() !== departmentTerm
7874
const changingFilter = people.isPlaceholderData || query !== search.trim()
7975

@@ -111,6 +107,7 @@ export function PeopleDirectory({ organizationId }: PeopleProps) {
111107

112108
const searchConfig: SearchConfig = {
113109
value: search,
110+
maxLength: PEOPLE_SEARCH_MAX_LENGTH,
114111
onChange: setSearch,
115112
onClearAll: () => setSearch(''),
116113
placeholder: PEOPLE_SEARCH_PLACEHOLDER,
@@ -147,7 +144,7 @@ export function PeopleDirectory({ organizationId }: PeopleProps) {
147144
<ChipCombobox
148145
options={[
149146
{ value: ALL_DEPARTMENTS, label: 'All' },
150-
...(departmentWaiting || searchTooLong
147+
...(departmentWaiting
151148
? []
152149
: (departments.data?.pages.flatMap((page) => page.departments) ?? [])
153150
).map(({ name, count, departmentKey: key }) => ({
@@ -160,23 +157,20 @@ export function PeopleDirectory({ organizationId }: PeopleProps) {
160157
overlayContent={departmentLabel || 'All'}
161158
overlayLabel={departmentLabel || 'All'}
162159
searchable
160+
searchMaxLength={PEOPLE_SEARCH_MAX_LENGTH}
163161
searchMode='server'
164162
onSearchChange={setDepartmentSearch}
165163
onOpenChange={setDepartmentsOpen}
166-
hasMore={!searchTooLong && !departmentWaiting && departments.hasNextPage}
164+
hasMore={!departmentWaiting && departments.hasNextPage}
167165
onLoadMore={() => void departments.fetchNextPage()}
168166
isLoadingMore={departments.isFetchingNextPage}
169-
isLoading={!searchTooLong && (departmentWaiting || departments.isPending)}
170-
error={
171-
searchTooLong
172-
? 'Search using up to 200 characters from the department name.'
173-
: departments.error?.message
174-
}
167+
isLoading={departmentWaiting || departments.isPending}
168+
error={departments.error?.message}
175169
searchPlaceholder='Search departments...'
176170
emptyMessage='No departments match'
177171
className='w-full'
178172
/>
179-
{!searchTooLong && departments.isError && (
173+
{departments.isError && (
180174
<Chip
181175
disabled={departments.isFetching}
182176
onClick={() => void departments.refetch()}

‎apps/sim/app/o/[organizationId]/people/search-params.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
import { createSerializer, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
1+
import { truncateAtCodePoint } from '@sim/utils/string'
2+
import {
3+
createParser,
4+
createSerializer,
5+
parseAsInteger,
6+
parseAsString,
7+
parseAsStringLiteral,
8+
} from 'nuqs/server'
9+
import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
10+
11+
const boundedSearch = (value: string) => truncateAtCodePoint(value, PEOPLE_SEARCH_MAX_LENGTH, '')
212

313
/** `search` matches name, email, title, department, or location; `department` narrows to one. */
414
export const peopleParsers = {
515
view: parseAsStringLiteral(['chart', 'directory'] as const),
616
panel: parseAsStringLiteral(['sync'] as const),
717
focus: parseAsString.withDefault(''),
818
page: parseAsInteger.withDefault(1),
9-
search: parseAsString.withDefault(''),
19+
search: createParser({ parse: boundedSearch, serialize: boundedSearch }).withDefault(''),
1020
department: parseAsString.withDefault(''),
1121
departmentKey: parseAsString.withDefault(''),
1222
} as const

‎apps/sim/app/o/[organizationId]/people/use-people-picker.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react'
22
import { Avatar } from '@sim/emcn'
3+
import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
34
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
45
import { usePeople } from '@/hooks/queries/people'
56
import { useDebounce } from '@/hooks/use-debounce'
@@ -26,6 +27,7 @@ export function usePeoplePicker(
2627
people,
2728
pickerProps: {
2829
searchable: true,
30+
searchMaxLength: PEOPLE_SEARCH_MAX_LENGTH,
2931
searchMode: 'server' as const,
3032
onSearchChange: setSearch,
3133
onOpenChange: setOpen,

‎apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface SearchTag {
6464

6565
export interface SearchConfig {
6666
value: string
67+
maxLength?: number
6768
onChange: (value: string) => void
6869
placeholder?: string
6970
inputRef?: React.RefObject<HTMLInputElement | null>
@@ -280,6 +281,7 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
280281
ref={search.inputRef}
281282
type='text'
282283
value={search.value}
284+
maxLength={search.maxLength}
283285
onChange={(e) => search.onChange(e.target.value)}
284286
onKeyDown={search.onKeyDown}
285287
onFocus={search.onFocus}

‎apps/sim/hooks/queries/people-sync.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useRef } from 'react'
2-
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2+
import { type QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
33
import { isApiClientError } from '@/lib/api/client/errors'
44
import { requestJson } from '@/lib/api/client/request'
5+
import type { ContractJsonResponse } from '@/lib/api/contracts'
56
import {
67
configurePeopleSyncContract,
78
disconnectPeopleSyncContract,
@@ -20,11 +21,27 @@ const syncKeys = {
2021
organization: (organizationId: string) => [...syncKeys.all, organizationId] as const,
2122
}
2223

24+
type SyncResult = ContractJsonResponse<typeof getPeopleSyncContract>
25+
26+
function refreshPublishedPeople(client: QueryClient, organizationId: string, result: SyncResult) {
27+
const previous = client.getQueryData<SyncResult>(syncKeys.organization(organizationId))
28+
if ((previous?.sync?.lastSuccessAt ?? null) !== (result.sync?.lastSuccessAt ?? null)) {
29+
return client.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) })
30+
}
31+
}
32+
2333
export function usePeopleSyncStatus(organizationId: string) {
34+
const client = useQueryClient()
2435
return useQuery({
2536
queryKey: syncKeys.organization(organizationId),
26-
queryFn: ({ signal }) =>
27-
requestJson(getPeopleSyncContract, { params: { id: organizationId }, signal }),
37+
queryFn: async ({ signal }) => {
38+
const result = await requestJson(getPeopleSyncContract, {
39+
params: { id: organizationId },
40+
signal,
41+
})
42+
if (!signal.aborted) void refreshPublishedPeople(client, organizationId, result)
43+
return result
44+
},
2845
staleTime: SYNC_STATUS_STALE_TIME,
2946
refetchInterval: (query) => {
3047
const sync = query.state.data?.sync
@@ -62,11 +79,13 @@ export function usePeopleSyncActions(organizationId: string) {
6279
if (!body) throw new Error('Choose a directory connection')
6380
return requestJson(configurePeopleSyncContract, { params, body })
6481
},
65-
onSuccess: () =>
66-
Promise.all([
67-
client.invalidateQueries({ queryKey: syncKeys.organization(organizationId) }),
68-
client.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) }),
69-
]),
82+
onSuccess: async (result) => {
83+
const queryKey = syncKeys.organization(organizationId)
84+
await client.cancelQueries({ queryKey })
85+
const refreshed = refreshPublishedPeople(client, organizationId, result)
86+
client.setQueryData(queryKey, result)
87+
await refreshed
88+
},
7089
onError: () => client.invalidateQueries({ queryKey: syncKeys.organization(organizationId) }),
7190
onSettled: () => {
7291
busy.current = false

‎apps/sim/lib/api/contracts/people-sync.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const status = z.object({
4949
})
5050
.nullable(),
5151
retryAt: z.iso.datetime().nullable(),
52+
retryBlocked: z.boolean(),
5253
})
5354
export type PeopleSyncStatus = z.infer<typeof status>
5455

‎apps/sim/lib/api/contracts/people.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
defineRouteContract,
66
} from '@/lib/api/contracts'
77
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
8-
import { DIRECTORY_TEXT_MAX_LENGTH, memberProfileFieldsSchema } from '@/lib/people/validation'
8+
import {
9+
DIRECTORY_TEXT_MAX_LENGTH,
10+
memberProfileFieldsSchema,
11+
PEOPLE_SEARCH_MAX_LENGTH,
12+
} from '@/lib/people/validation'
913

1014
/** Rows one import may carry; larger organizations import in several files. */
1115
export const PEOPLE_IMPORT_MAX_ROWS = 10_000
@@ -48,7 +52,7 @@ export const listPeopleContract = defineRouteContract({
4852
params,
4953
query: z.object({
5054
scope: z.enum(['roots', 'members']).optional(),
51-
search: z.string().max(200).optional(),
55+
search: z.string().max(PEOPLE_SEARCH_MAX_LENGTH).optional(),
5256
department: z.string().max(DIRECTORY_TEXT_MAX_LENGTH).optional(),
5357
departmentKey: departmentKey.optional(),
5458
cursor: z.string().min(1).max(1024).optional(),
@@ -68,7 +72,7 @@ export const listDepartmentsContract = defineRouteContract({
6872
path: '/api/organizations/[id]/people/departments',
6973
params,
7074
query: z.object({
71-
search: z.string().max(200).optional(),
75+
search: z.string().max(PEOPLE_SEARCH_MAX_LENGTH).optional(),
7276
cursor: z.string().min(1).max(1024).optional(),
7377
}),
7478
response: {

‎apps/sim/lib/people/repository.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type PersonRecord = Omit<Person, 'joinedAt' | 'departmentKey'> & { joined
4242
const ACTIVE_USER = isNull(user.suspendedAt)
4343
const SUMMARY_FIELDS = sql`p.id AS "memberId", p.user_id AS "userId", p.name, p.email, p.image, p.title`
4444

45-
/** Retire SCIM identities in the same commit as provisioning, before a later snapshot reclaims the row. */
45+
/** Hide retired SCIM identities immediately while the previous snapshot awaits cleanup. */
4646
function visibleDirectoryEntry(organizationId: string) {
4747
return sql`(d.source <> 'scim' OR EXISTS (
4848
SELECT 1 FROM ${scimUser} su JOIN ${user} account ON account.id = su.user_id

0 commit comments

Comments
 (0)