mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
saved session replay implementation checkpoint
This commit is contained in:
Generated
-49
@@ -334,8 +334,6 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
dist: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
@@ -533,28 +531,24 @@ packages:
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.2':
|
||||
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.2':
|
||||
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
|
||||
@@ -1318,105 +1312,89 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -1617,28 +1595,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||
@@ -1693,42 +1667,36 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.6':
|
||||
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
||||
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.6':
|
||||
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
|
||||
@@ -2604,79 +2572,66 @@ packages:
|
||||
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
|
||||
@@ -2841,28 +2796,24 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.11':
|
||||
resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.11':
|
||||
resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.11':
|
||||
resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.11':
|
||||
resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==}
|
||||
|
||||
+18
@@ -25,3 +25,21 @@ CREATE INDEX "session_replay_website_id_session_id_idx" ON "session_replay"("web
|
||||
CREATE INDEX "session_replay_website_id_visit_id_idx" ON "session_replay"("website_id", "visit_id");
|
||||
CREATE INDEX "session_replay_website_id_created_at_idx" ON "session_replay"("website_id", "created_at");
|
||||
CREATE INDEX "session_replay_session_id_chunk_index_idx" ON "session_replay"("session_id", "chunk_index");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session_replay_saved" (
|
||||
"saved_replay_id" UUID NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "session_replay_saved_pkey" PRIMARY KEY ("saved_replay_id"),
|
||||
CONSTRAINT "session_replay_saved_website_id_visit_id_key" UNIQUE ("website_id", "visit_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_replay_saved_website_id_idx" ON "session_replay_saved"("website_id");
|
||||
CREATE INDEX "session_replay_saved_visit_id_idx" ON "session_replay_saved"("visit_id");
|
||||
CREATE INDEX "session_replay_saved_website_id_created_at_idx" ON "session_replay_saved"("website_id", "created_at");
|
||||
+27
-9
@@ -78,15 +78,16 @@ model Website {
|
||||
replayEnabled Boolean @default(false) @map("replay_enabled")
|
||||
replayConfig Json? @map("replay_config")
|
||||
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
eventData EventData[]
|
||||
reports Report[]
|
||||
revenue Revenue[]
|
||||
segments Segment[]
|
||||
sessionData SessionData[]
|
||||
sessionReplays SessionReplay[]
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
eventData EventData[]
|
||||
reports Report[]
|
||||
revenue Revenue[]
|
||||
segments Segment[]
|
||||
sessionData SessionData[]
|
||||
sessionReplays SessionReplay[]
|
||||
sessionReplaysSaved SessionReplaySaved[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@ -383,3 +384,20 @@ model SessionReplay {
|
||||
@@index([sessionId, chunkIndex])
|
||||
@@map("session_replay")
|
||||
}
|
||||
|
||||
model SessionReplaySaved {
|
||||
id String @id() @map("saved_replay_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@unique([websiteId, visitId])
|
||||
@@index([websiteId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@map("session_replay_saved")
|
||||
}
|
||||
@@ -270,6 +270,7 @@
|
||||
"run-query": "Run query",
|
||||
"save": "Save",
|
||||
"save-cohort": "Save cohort",
|
||||
"save-replay": "Save replay",
|
||||
"save-segment": "Save segment",
|
||||
"sample-rate": "Sample rate",
|
||||
"sample-size": "Sample size",
|
||||
@@ -344,6 +345,7 @@
|
||||
"uniqueCustomers": "Unique Customers",
|
||||
"customers": "Customers",
|
||||
"orders": "Orders",
|
||||
"saved": "Saved",
|
||||
"unknown": "Unknown",
|
||||
"untitled": "Untitled",
|
||||
"update": "Update",
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
||||
import { type Key, useState } from 'react';
|
||||
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import { ReplayModal } from './ReplayModal';
|
||||
import { ReplaysDataTable } from './ReplaysDataTable';
|
||||
import { SavedReplaysDataTable } from './SavedReplaysDataTable';
|
||||
|
||||
const KEY_NAME = 'umami.replays.tab';
|
||||
|
||||
export function ReplaysPage({ websiteId }: { websiteId: string }) {
|
||||
const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays');
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
const handleSelect = (value: Key) => {
|
||||
setItem(KEY_NAME, value);
|
||||
setTab(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Column gap="3">
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Panel>
|
||||
<ReplaysDataTable websiteId={websiteId} />
|
||||
<Tabs selectedKey={tab} onSelectionChange={handleSelect}>
|
||||
<TabList>
|
||||
<Tab id="replays">{t(labels.replays)}</Tab>
|
||||
<Tab id="saved">{t(labels.saved)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="replays">
|
||||
<ReplaysDataTable websiteId={websiteId} />
|
||||
</TabPanel>
|
||||
<TabPanel id="saved">
|
||||
<SavedReplaysDataTable websiteId={websiteId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<SessionModal websiteId={websiteId} />
|
||||
<ReplayModal websiteId={websiteId} />
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ReplaysTable({ ...props }: DataTableProps) {
|
||||
</TypeIcon>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="140px">
|
||||
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="160px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useSavedReplaysQuery } from '@/components/hooks';
|
||||
import { SavedReplaysTable } from './SavedReplaysTable';
|
||||
|
||||
export function SavedReplaysDataTable({ websiteId }: { websiteId: string }) {
|
||||
const queryResult = useSavedReplaysQuery(websiteId);
|
||||
|
||||
return (
|
||||
<DataGrid query={queryResult} allowPaging allowSearch>
|
||||
{({ data }) => {
|
||||
return <SavedReplaysTable data={data} />;
|
||||
}}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
|
||||
import { Play } from 'lucide-react';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
|
||||
export function SavedReplaysTable({ ...props }: DataTableProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { router, updateParams } = useNavigation();
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="play" label="" width="80px">
|
||||
{(row: any) => (
|
||||
<Button
|
||||
variant="quiet"
|
||||
onClick={() => router.push(updateParams({ replay: row.visitId }))}
|
||||
>
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="name" label={t(labels.name)} />
|
||||
<DataColumn id="visitId" label={t(labels.replayId)} />
|
||||
<DataColumn id="createdAt" label={t(labels.created)} width="160px">
|
||||
{(row: any) => new Date(row.createdAt).toLocaleString()}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
'use client';
|
||||
import { Button, Column, Icon, Row, Text } from '@umami/react-zen';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button, Column, Dialog, DialogTrigger, Icon, Popover, Row, Text } from '@umami/react-zen';
|
||||
import { Bookmark, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks';
|
||||
import {
|
||||
useMessages,
|
||||
useReplayQuery,
|
||||
useUpdateQuery,
|
||||
useWebsiteSessionQuery,
|
||||
} from '@/components/hooks';
|
||||
import { touch } from '@/components/hooks/useModified';
|
||||
import { ReplayPlayer } from './ReplayPlayer';
|
||||
import { ReplaySaveForm } from './ReplaySaveForm';
|
||||
|
||||
export function ReplayPlayback({
|
||||
websiteId,
|
||||
@@ -19,6 +27,15 @@ export function ReplayPlayback({
|
||||
const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId);
|
||||
const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId);
|
||||
const { t, labels } = useMessages();
|
||||
const [isSaved, setIsSaved] = useState<boolean | null>(null);
|
||||
const { mutate } = useUpdateQuery(`/websites/${websiteId}/replays/${replayId}`);
|
||||
|
||||
const saved = isSaved ?? replay?.isSaved ?? false;
|
||||
|
||||
const handleUnsave = () => {
|
||||
setIsSaved(false);
|
||||
mutate({ isSaved: false }, { onSuccess: () => touch('replays') });
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
@@ -41,13 +58,45 @@ export function ReplayPlayback({
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
{onClose && (
|
||||
<Button onPress={onClose} variant="quiet">
|
||||
<Icon>
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
<Row gap="2">
|
||||
{saved ? (
|
||||
<Button onPress={handleUnsave} variant="quiet">
|
||||
<Icon>
|
||||
<Bookmark fill="currentColor" />
|
||||
</Icon>
|
||||
</Button>
|
||||
) : (
|
||||
<DialogTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<Bookmark fill="none" />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom end">
|
||||
<Dialog title={t(labels.saveReplay)} style={{ width: '300px' }}>
|
||||
{({ close }) => (
|
||||
<ReplaySaveForm
|
||||
websiteId={websiteId}
|
||||
replayId={replayId}
|
||||
onSave={() => {
|
||||
setIsSaved(true);
|
||||
touch('replays');
|
||||
}}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button onPress={onClose} variant="quiet">
|
||||
<Icon>
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
)}
|
||||
<ReplayPlayer events={replay.events} />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormField,
|
||||
FormSubmitButton,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
|
||||
export function ReplaySaveForm({
|
||||
websiteId,
|
||||
replayId,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
websiteId: string;
|
||||
replayId: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels, getErrorMessage } = useMessages();
|
||||
const { mutateAsync, error, isPending } = useUpdateQuery(
|
||||
`/websites/${websiteId}/replays/${replayId}`,
|
||||
);
|
||||
|
||||
const handleSubmit = async (formData: { name: string }) => {
|
||||
await mutateAsync(
|
||||
{ isSaved: true, name: formData.name },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
|
||||
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
|
||||
<TextField autoFocus />
|
||||
</FormField>
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||
{t(labels.save)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function SessionReplaysTable({
|
||||
{(row: any) => formatDuration(row.duration || 0)}
|
||||
</DataColumn>
|
||||
<DataColumn id="eventCount" label={t(labels.actions)} width="80px" />
|
||||
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="140px">
|
||||
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="160px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
|
||||
import {
|
||||
createReplaySaved,
|
||||
deleteReplaySaved,
|
||||
getReplaySaved,
|
||||
} from '@/queries/prisma/sessionReplay';
|
||||
import { getReplayChunks } from '@/queries/sql';
|
||||
|
||||
export async function GET(
|
||||
@@ -19,7 +25,10 @@ export async function GET(
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const chunks = await getReplayChunks(websiteId, replayId);
|
||||
const [chunks, isSaved] = await Promise.all([
|
||||
getReplayChunks(websiteId, replayId),
|
||||
getReplaySaved(websiteId, replayId),
|
||||
]);
|
||||
|
||||
const allEvents = chunks.flatMap(chunk => chunk.events);
|
||||
const sessionId = chunks.length > 0 ? chunks[0].sessionId : null;
|
||||
@@ -33,5 +42,35 @@ export async function GET(
|
||||
endedAt,
|
||||
eventCount: allEvents.length,
|
||||
chunkCount: chunks.length,
|
||||
isSaved,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; replayId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
isSaved: z.boolean(),
|
||||
name: z.string().max(100).optional(),
|
||||
});
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, replayId } = await params;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (body.isSaved) {
|
||||
await createReplaySaved(websiteId, replayId, body.name ?? '');
|
||||
} else {
|
||||
await deleteReplaySaved(websiteId, replayId);
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getSavedReplays } from '@/queries/prisma/sessionReplay';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getSavedReplays(websiteId, query);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export * from './queries/useReportQuery';
|
||||
export * from './queries/useReportsQuery';
|
||||
export * from './queries/useResultQuery';
|
||||
export * from './queries/useRevenueSessionsQuery';
|
||||
export * from './queries/useSavedReplaysQuery';
|
||||
export * from './queries/useSessionActivityQuery';
|
||||
export * from './queries/useSessionDataPropertiesQuery';
|
||||
export * from './queries/useSessionDataQuery';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function useSavedReplaysQuery(websiteId: string) {
|
||||
const { get } = useApi();
|
||||
const { modified } = useModified('replays');
|
||||
|
||||
return usePagedQuery({
|
||||
queryKey: ['replays:saved', { websiteId, modified }],
|
||||
queryFn: pageParams => {
|
||||
return get(`/websites/${websiteId}/replays/saved`, {
|
||||
...pageParams,
|
||||
pageSize: 20,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -343,6 +343,7 @@ export const labels: Record<string, string> = {
|
||||
version: 'label.version',
|
||||
saveSegment: 'label.save-segment',
|
||||
saveCohort: 'label.save-cohort',
|
||||
saveReplay: 'label.save-replay',
|
||||
analysis: 'label.analysis',
|
||||
destinationUrl: 'label.destination-url',
|
||||
slug: 'label.slug',
|
||||
@@ -366,6 +367,7 @@ export const labels: Record<string, string> = {
|
||||
sampleSize: 'label.sample-size',
|
||||
play: 'label.play',
|
||||
replays: 'label.replays',
|
||||
saved: 'label.saved',
|
||||
replay: 'label.replay',
|
||||
replayId: 'label.replay-id',
|
||||
replayEnabled: 'label.replay-enabled',
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ListTable({
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
paddingLeft="2"
|
||||
columns={`1fr ${showPercentage ? '100px' : '150px'}`}
|
||||
columns={'1fr 100px'}
|
||||
>
|
||||
<Text weight="bold">{title}</Text>
|
||||
<Text weight="bold" align="center">
|
||||
@@ -117,7 +117,7 @@ const AnimatedRow = ({
|
||||
|
||||
return (
|
||||
<Grid
|
||||
columns="1fr 50px 50px"
|
||||
columns={showPercentage ? '1fr 50px 50px' : '1fr 100px'}
|
||||
paddingLeft="2"
|
||||
alignItems="center"
|
||||
borderRadius
|
||||
@@ -129,7 +129,11 @@ const AnimatedRow = ({
|
||||
{label}
|
||||
</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" height="30px" justifyContent="flex-end">
|
||||
<Row
|
||||
alignItems="center"
|
||||
height="30px"
|
||||
justifyContent={showPercentage ? 'flex-end' : 'center'}
|
||||
>
|
||||
{change}
|
||||
<Text weight="bold">
|
||||
<AnimatedDiv title={props?.y as any}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
export interface CreateReplayChunkArgs {
|
||||
websiteId: string;
|
||||
@@ -62,3 +63,49 @@ export async function deleteReplaysByWebsite(websiteId: string) {
|
||||
where: { websiteId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReplaySaved(websiteId: string, visitId: string): Promise<boolean> {
|
||||
const record = await prisma.client.sessionReplaySaved.findUnique({
|
||||
where: { websiteId_visitId: { websiteId, visitId } },
|
||||
select: { id: true },
|
||||
});
|
||||
return record !== null;
|
||||
}
|
||||
|
||||
export async function createReplaySaved(websiteId: string, visitId: string, name: string) {
|
||||
return prisma.client.sessionReplaySaved.create({
|
||||
data: { id: uuid(), websiteId, visitId, name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateReplaySaved(websiteId: string, visitId: string, name: string) {
|
||||
return prisma.client.sessionReplaySaved.updateMany({
|
||||
where: { websiteId, visitId },
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteReplaySaved(websiteId: string, visitId: string) {
|
||||
return prisma.client.sessionReplaySaved.deleteMany({
|
||||
where: { websiteId, visitId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSavedReplays(websiteId: string, filters: QueryFilters) {
|
||||
const { search } = filters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
||||
const where = {
|
||||
websiteId,
|
||||
...getSearchParameters(search, [{ name: 'contains' }, { visitId: 'contains' }]),
|
||||
};
|
||||
|
||||
return pagedQuery(
|
||||
'sessionReplaySaved',
|
||||
{
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
filters,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ export async function resetWebsite(websiteId: string) {
|
||||
|
||||
return transaction(
|
||||
async tx => {
|
||||
await tx.sessionReplaySaved.deleteMany({
|
||||
where: { websiteId },
|
||||
});
|
||||
|
||||
await tx.sessionReplay.deleteMany({
|
||||
where: { websiteId },
|
||||
});
|
||||
@@ -187,6 +191,10 @@ export async function deleteWebsite(websiteId: string) {
|
||||
|
||||
return transaction(
|
||||
async tx => {
|
||||
await tx.sessionReplaySaved.deleteMany({
|
||||
where: { websiteId },
|
||||
});
|
||||
|
||||
await tx.sessionReplay.deleteMany({
|
||||
where: { websiteId },
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ async function clickhouseQuery(
|
||||
),
|
||||
user_activities AS (
|
||||
select distinct
|
||||
website_event.session_id,
|
||||
website_event.session_id as session_id,
|
||||
toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number
|
||||
from website_event
|
||||
join cohort_items
|
||||
|
||||
Reference in New Issue
Block a user