1- /** Real PostgreSQL name allocation and name lookups for workspace files, plus the URL fetch path. */
1+ /**
2+ * Real PostgreSQL name allocation and name lookups for workspace files and chat uploads, plus the
3+ * URL fetch path.
4+ */
25import { mkdtempSync } from 'node:fs'
36import { rm } from 'node:fs/promises'
47import { tmpdir } from 'node:os'
58import path from 'node:path'
69import { db , dbFor } from '@sim/db'
7- import { organization , user , workspace , workspaceFiles } from '@sim/db/schema'
10+ import { copilotChats , organization , user , workspace , workspaceFiles } from '@sim/db/schema'
811import { generateId } from '@sim/utils/id'
912import { and , eq , inArray , isNull , sql } from 'drizzle-orm'
1013import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
@@ -29,8 +32,12 @@ import {
2932 workspaceFileNameFolderCondition ,
3033} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
3134import {
35+ generateWorkspaceFileKey ,
3236 getWorkspaceFileByName ,
37+ resolveWorkspaceFileReference ,
38+ trackChatUpload ,
3339 uploadWorkspaceFile ,
40+ workspaceFileVfsPath ,
3441} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
3542import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
3643
@@ -66,6 +73,19 @@ describe('workspace file names in PostgreSQL', () => {
6673 } )
6774 }
6875
76+ async function seedChat ( workspaceId : string , userId : string ) {
77+ const chatId = generateId ( )
78+ await db . insert ( copilotChats ) . values ( { id : chatId , userId, workspaceId, type : 'mothership' } )
79+ return chatId
80+ }
81+
82+ async function trackUpload ( workspaceId : string , userId : string , chatId : string , name : string ) {
83+ const key = generateWorkspaceFileKey ( workspaceId , name )
84+ await trackChatUpload ( workspaceId , userId , chatId , key , name , 'image/png' , 10 )
85+ const [ row ] = await db . select ( ) . from ( workspaceFiles ) . where ( eq ( workspaceFiles . key , key ) )
86+ return row
87+ }
88+
6989 async function parseExternalUrl ( executionId ?: string ) {
7090 const fixture = await seedWorkspace ( )
7191 const url = 'https://example.com/page.txt'
@@ -194,4 +214,78 @@ describe('workspace file names in PostgreSQL', () => {
194214 for ( const name of names ) expect ( name ) . toMatch ( shortIdSuffixed )
195215 expect ( new Set ( [ next . name , ...names ] ) . size ) . toBe ( names . length + 1 )
196216 } )
217+
218+ it ( 'resolves a chat upload by the path its upload notice prints when the name is not in VFS form' , async ( ) => {
219+ const fixture = await seedWorkspace ( )
220+ const chatId = await seedChat ( fixture . workspaceId , fixture . aliceId )
221+ const otherChatId = await seedChat ( fixture . workspaceId , fixture . aliceId )
222+ // macOS screenshot names carry U+202F before AM/PM, pasted names keep doubled spaces, some
223+ // pickers report decomposed (NFD) accents, and control characters drop out of VFS names.
224+ const names = [
225+ 'Screenshot 2026-01-15 at 9.41.07\u202fAM.png' ,
226+ 'Quarterly Report.pdf' ,
227+ 'Cafe\u0301 menu.png' ,
228+ 'ring\u0007ing.png' ,
229+ 'trail.png \u0007' ,
230+ ]
231+ for ( const name of names ) {
232+ const row = await trackUpload ( fixture . workspaceId , fixture . aliceId , chatId , name )
233+ const noticePath = workspaceFileVfsPath ( { folderPath : null , name, vfsNamespace : 'uploads' } )
234+ for ( const reference of [ noticePath , `uploads/${ name } ` ] ) {
235+ for ( const options of [ { chatId } , { } ] ) {
236+ const record = await resolveWorkspaceFileReference ( fixture . workspaceId , reference , {
237+ includeChatUploads : true ,
238+ ...options ,
239+ } )
240+ expect ( record ?. id ) . toBe ( row . id )
241+ expect ( record ?. name ) . toBe ( name )
242+ }
243+ }
244+ expect (
245+ await resolveWorkspaceFileReference ( fixture . workspaceId , noticePath , {
246+ includeChatUploads : true ,
247+ chatId : otherChatId ,
248+ } )
249+ ) . toBeNull ( )
250+ }
251+ } )
252+
253+ it ( 'matches a chat upload name exactly, never as a pattern or a fragment' , async ( ) => {
254+ const fixture = await seedWorkspace ( )
255+ const chatId = await seedChat ( fixture . workspaceId , fixture . aliceId )
256+ await trackUpload ( fixture . workspaceId , fixture . aliceId , chatId , 'axb.png' )
257+ await trackUpload ( fixture . workspaceId , fixture . aliceId , chatId , 'my notes.png.bak' )
258+ await trackUpload ( fixture . workspaceId , fixture . aliceId , chatId , 'notes 100%.png' )
259+ await trackUpload ( fixture . workspaceId , fixture . aliceId , chatId , 'back\\slash.png' )
260+
261+ for ( const reference of [
262+ 'uploads/a_b.png' ,
263+ 'uploads/a.b.png' ,
264+ 'uploads/notes.png' ,
265+ 'uploads/notes%20%25.png' ,
266+ ] ) {
267+ expect (
268+ await resolveWorkspaceFileReference ( fixture . workspaceId , reference , {
269+ includeChatUploads : true ,
270+ chatId,
271+ } )
272+ ) . toBeNull ( )
273+ }
274+ expect (
275+ (
276+ await resolveWorkspaceFileReference ( fixture . workspaceId , 'uploads/notes%20100%25.png' , {
277+ includeChatUploads : true ,
278+ chatId,
279+ } )
280+ ) ?. name
281+ ) . toBe ( 'notes 100%.png' )
282+ expect (
283+ (
284+ await resolveWorkspaceFileReference ( fixture . workspaceId , 'uploads/back%5Cslash.png' , {
285+ includeChatUploads : true ,
286+ chatId,
287+ } )
288+ ) ?. name
289+ ) . toBe ( 'back\\slash.png' )
290+ } )
197291} )
0 commit comments