Skip to content

Commit 653acf7

Browse files
committed
fix(sim-links): keep slashes in typed values, show most-used as one page, format migration meta
1 parent a51b236 commit 653acf7

7 files changed

Lines changed: 841 additions & 2577 deletions

File tree

‎apps/docs/content/docs/search/sim-links.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Short sim/ links that take everyone in your organization to the rig
55

66
Sim Links are short, memorable names for the pages your team opens often, like `sim/handbook` for the employee handbook or `sim/okta` for your sign-in portal. Anyone in the organization can open them, with or without a Search seat. They work like go links in other tools.
77

8-
Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it, how often it's opened, and when it last changed. Search by name, destination, or description, and select **Most used** to see the links your team relies on most.
8+
Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it, how often it's opened, and when it last changed. Search by name, destination, or description, and select **Most used** to see the 50 links your team opens most.
99

1010
## Open a link
1111

‎apps/sim/app/sim/[[...path]]/page.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ interface SimLinkPageProps {
2323

2424
/**
2525
* The typed `sim/<name>/<values…>` path as plain text. Next passes catch-all segments still
26-
* percent-encoded (it already rejects malformed escapes with a 400), and a browser site-search
27-
* shortcut encodes `/` inside a single segment.
26+
* percent-encoded (it already rejects malformed escapes with a 400). A browser site-search
27+
* shortcut sends `sim jira/123` as one segment with its `/` encoded, so only a lone segment is
28+
* split; separately typed values keep any `/` they contain.
2829
*/
2930
function decodeSimLinkPath(path: string[]): string[] {
30-
return path.flatMap((segment) => decodeURIComponent(segment).split('/')).filter(Boolean)
31+
const segments = path.map(decodeURIComponent)
32+
return segments.length === 1 ? segments[0].split('/').filter(Boolean) : segments
3133
}
3234

3335
/**

‎apps/sim/lib/api/contracts/sim-links.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,16 @@ export const listSimLinksContract = defineRouteContract({
3434
method: 'GET',
3535
path: '/api/organizations/[id]/sim-links',
3636
params,
37-
query: z.object({
38-
search: z.string().max(200).optional(),
39-
cursor: z.string().min(1).max(128).optional(),
40-
sort: z.enum(SIM_LINK_SORTS).optional(),
41-
}),
37+
query: z
38+
.object({
39+
search: z.string().max(200).optional(),
40+
cursor: z.string().min(1).max(128).optional(),
41+
sort: z.enum(SIM_LINK_SORTS).optional(),
42+
})
43+
.refine((query) => !(query.sort === 'popular' && query.cursor), {
44+
path: ['cursor'],
45+
message: 'The most-used view is a single page',
46+
}),
4247
response: {
4348
mode: 'json',
4449
schema: z.object({ links: z.array(simLinkSchema), nextCursor: z.string().nullable() }),

‎apps/sim/lib/sim-links/repository.ts‎

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { db } from '@sim/db'
22
import { simLink, user } from '@sim/db/schema'
33
import { generateId } from '@sim/utils/id'
44
import { escapeLikePattern } from '@sim/utils/string'
5-
import { and, asc, desc, eq, gt, ilike, lt, or, type SQL, sql } from 'drizzle-orm'
6-
import { OrchestrationError } from '@/lib/core/orchestration/types'
5+
import { and, asc, desc, eq, gt, ilike, or, type SQL, sql } from 'drizzle-orm'
76
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
87
import {
98
normalizeSimLinkName,
@@ -73,30 +72,9 @@ function toRecord({
7372
}
7473

7574
/**
76-
* A keyset position: the last row's normalized name, preceded by its visit count when sorting
77-
* by use. Normalized names never contain `:`, so the first one separates the two.
78-
*/
79-
function cursorOf(sort: SimLinkSort, row: SimLinkRecord): string {
80-
return sort === 'popular' ? `${row.visitCount}:${row.normalizedName}` : row.normalizedName
81-
}
82-
83-
function afterCursor(sort: SimLinkSort, cursor: string): SQL | undefined {
84-
if (sort === 'name') return gt(simLink.normalizedName, cursor)
85-
const separator = cursor.indexOf(':')
86-
const visits = Number(cursor.slice(0, separator))
87-
if (separator < 1 || !Number.isSafeInteger(visits)) {
88-
throw new OrchestrationError('validation', 'Invalid cursor')
89-
}
90-
const name = cursor.slice(separator + 1)
91-
return or(
92-
lt(simLink.visitCount, visits),
93-
and(eq(simLink.visitCount, visits), gt(simLink.normalizedName, name))
94-
)
95-
}
96-
97-
/**
98-
* One page of the links `viewer` may browse, by name or by most visited. Unlisted links are
99-
* listed only to their creator and organization admins.
75+
* The links `viewer` may browse: pages by name, or the most visited as a single page, since a
76+
* cursor over live visit counts would skip links that gain visits between pages. Unlisted links
77+
* are listed only to their creator and organization admins.
10078
*/
10179
export async function listSimLinks(
10280
organizationId: string,
@@ -119,18 +97,23 @@ export async function listSimLinks(
11997
)
12098
)
12199
}
122-
if (cursor) conditions.push(afterCursor(sort, cursor))
123-
const order =
124-
sort === 'popular'
125-
? [desc(simLink.visitCount), asc(simLink.normalizedName)]
126-
: [asc(simLink.normalizedName)]
100+
if (sort === 'popular') {
101+
const rows = await selectRecords(db)
102+
.where(and(...conditions))
103+
.orderBy(desc(simLink.visitCount), asc(simLink.normalizedName))
104+
.limit(PAGE_SIZE)
105+
return { links: rows.map(toRecord), nextCursor: null }
106+
}
107+
if (cursor) conditions.push(gt(simLink.normalizedName, cursor))
127108
const rows = await selectRecords(db)
128109
.where(and(...conditions))
129-
.orderBy(...order)
110+
.orderBy(asc(simLink.normalizedName))
130111
.limit(PAGE_SIZE + 1)
131112
const links = rows.slice(0, PAGE_SIZE).map(toRecord)
132-
const last = links.at(-1)
133-
return { links, nextCursor: rows.length > PAGE_SIZE && last ? cursorOf(sort, last) : null }
113+
return {
114+
links,
115+
nextCursor: rows.length > PAGE_SIZE ? (links.at(-1)?.normalizedName ?? null) : null,
116+
}
134117
}
135118

136119
export async function getSimLink(

‎apps/sim/scripts/test-sim-links-e2e.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ async function run() {
218218
(await open(member, 'jira%2F123')).location,
219219
'https://jira.example.com/browse/PROD-123'
220220
)
221+
await create(owner, { name: 'go', url: 'https://redirect.example.com/?to={*}' })
222+
assert.equal(
223+
(await open(member, 'go/https%3A%2F%2Fexample.com%2Fa')).location,
224+
'https://redirect.example.com/?to=https%3A%2F%2Fexample.com%2Fa'
225+
)
221226
assert.equal((await open(member, 'jira')).location, 'https://jira.example.com/')
222227
const hostile = new URL((await open(member, 'jira/%2F%2Fevil.example.org')).location ?? '')
223228
assert.equal(hostile.origin, 'https://jira.example.com')
@@ -326,7 +331,7 @@ async function run() {
326331
)
327332
assert.equal(visits.get('jira'), 4, 'Each jira redirect counts once')
328333
assert.equal(visits.get('gh'), 0, 'A link opened without its values is not a visit')
329-
await api(member, '?sort=popular&cursor=not-a-cursor', { expected: 400 })
334+
await api(member, '?sort=popular&cursor=jira', { expected: 400 })
330335
})
331336

332337
await check('deleting a link stops it resolving and every change is audited', async () => {

0 commit comments

Comments
 (0)