mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-05 03:25:10 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31071c8308 |
+1
-2
@@ -247,8 +247,7 @@ const config = {
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks:
|
||||
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)',
|
||||
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
TIME_VERSION=$(date +%Y%m%d%H%M)
|
||||
GIT_SHORT_HASH=$(git rev-parse --short HEAD)
|
||||
APP_VERSION=$PACKAGE_VERSION-nightly-$GIT_SHORT_HASH
|
||||
APP_VERSION=$PACKAGE_VERSION-$GIT_SHORT_HASH
|
||||
fi
|
||||
echo $APP_VERSION
|
||||
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.16.0"
|
||||
appVersion: "0.15.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.16.0"
|
||||
appVersion: "0.15.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.16.0"
|
||||
appVersion: "0.15.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
|
||||
path: builds
|
||||
|
||||
package-distribution-windows:
|
||||
make-distribution-windows-skip-signing:
|
||||
strategy:
|
||||
matrix:
|
||||
spec:
|
||||
@@ -191,8 +191,6 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.spec.runner }}
|
||||
needs: before-make
|
||||
outputs:
|
||||
FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
|
||||
env:
|
||||
SKIP_GENERATE_ASSETS: 1
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
@@ -232,111 +230,12 @@ jobs:
|
||||
SKIP_WEB_BUILD: 1
|
||||
HOIST_NODE_MODULES: 1
|
||||
|
||||
- name: get all files to be signed
|
||||
id: get_files_to_be_signed
|
||||
run: |
|
||||
Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\', '') + '"' }) -join ' ')
|
||||
"FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
|
||||
echo $FILES_TO_BE_SIGNED
|
||||
|
||||
- name: Zip artifacts for faster upload
|
||||
run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/* -DestinationPath archive.zip
|
||||
|
||||
- name: Save packaged artifacts for signing
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: |
|
||||
archive.zip
|
||||
!**/*.map
|
||||
|
||||
sign-packaged-artifacts-windows:
|
||||
needs: package-distribution-windows
|
||||
uses: ./.github/workflows/windows-signer.yml
|
||||
with:
|
||||
files: ${{ needs.package-distribution-windows.outputs.FILES_TO_BE_SIGNED }}
|
||||
artifact-name: packaged-win32-x64
|
||||
|
||||
make-windows-installer:
|
||||
needs: sign-packaged-artifacts-windows
|
||||
strategy:
|
||||
matrix:
|
||||
spec:
|
||||
- runner: windows-latest
|
||||
platform: win32
|
||||
arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.spec.runner }}
|
||||
outputs:
|
||||
FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
hard-link-nm: false
|
||||
nmHoistingLimits: workspaces
|
||||
- name: Download and overwrite packaged artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: .
|
||||
- name: unzip file
|
||||
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out
|
||||
|
||||
- name: Make squirrel.windows installer
|
||||
run: yarn workspace @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||
|
||||
- name: Make nsis.windows installer
|
||||
run: yarn workspace @affine/electron make-nsis --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||
|
||||
- name: Zip artifacts for faster upload
|
||||
run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/* -DestinationPath archive.zip
|
||||
|
||||
- name: get all files to be signed
|
||||
id: get_files_to_be_signed
|
||||
run: |
|
||||
Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\${{ env.BUILD_TYPE }}\make\', '') + '"' }) -join ' ')
|
||||
"FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
|
||||
echo $FILES_TO_BE_SIGNED
|
||||
|
||||
- name: Save installer for signing
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: archive.zip
|
||||
|
||||
sign-installer-artifacts-windows:
|
||||
needs: make-windows-installer
|
||||
uses: ./.github/workflows/windows-signer.yml
|
||||
with:
|
||||
files: ${{ needs.make-windows-installer.outputs.FILES_TO_BE_SIGNED }}
|
||||
artifact-name: installer-win32-x64
|
||||
|
||||
finalize-installer-windows:
|
||||
needs: [sign-installer-artifacts-windows, before-make]
|
||||
strategy:
|
||||
matrix:
|
||||
spec:
|
||||
- runner: windows-latest
|
||||
platform: win32
|
||||
arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.spec.runner }}
|
||||
steps:
|
||||
- name: Download and overwrite installer artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: .
|
||||
- name: unzip file
|
||||
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make
|
||||
|
||||
- name: Save artifacts
|
||||
run: |
|
||||
mkdir -p builds
|
||||
@@ -357,8 +256,180 @@ jobs:
|
||||
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
|
||||
path: builds
|
||||
|
||||
# package-distribution-windows:
|
||||
# strategy:
|
||||
# matrix:
|
||||
# spec:
|
||||
# - runner: windows-latest
|
||||
# platform: win32
|
||||
# arch: x64
|
||||
# target: x86_64-pc-windows-msvc
|
||||
# runs-on: ${{ matrix.spec.runner }}
|
||||
# needs: before-make
|
||||
# outputs:
|
||||
# FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
|
||||
# env:
|
||||
# SKIP_GENERATE_ASSETS: 1
|
||||
# SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
# SENTRY_PROJECT: 'affine'
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
# SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
# MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - name: Setup Version
|
||||
# id: version
|
||||
# uses: ./.github/actions/setup-version
|
||||
# - name: Setup Node.js
|
||||
# timeout-minutes: 10
|
||||
# uses: ./.github/actions/setup-node
|
||||
# with:
|
||||
# extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
# hard-link-nm: false
|
||||
# nmHoistingLimits: workspaces
|
||||
# - name: Build AFFiNE native
|
||||
# uses: ./.github/actions/build-rust
|
||||
# with:
|
||||
# target: ${{ matrix.spec.target }}
|
||||
# package: '@affine/native'
|
||||
# nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
# - uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: web
|
||||
# path: packages/frontend/electron/resources/web-static
|
||||
|
||||
# - name: Build Desktop Layers
|
||||
# run: yarn workspace @affine/electron build
|
||||
|
||||
# - name: package
|
||||
# run: yarn workspace @affine/electron package --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||
# env:
|
||||
# SKIP_WEB_BUILD: 1
|
||||
# HOIST_NODE_MODULES: 1
|
||||
|
||||
# - name: get all files to be signed
|
||||
# id: get_files_to_be_signed
|
||||
# run: |
|
||||
# Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\', '') + '"' }) -join ' ')
|
||||
# "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
|
||||
# echo $FILES_TO_BE_SIGNED
|
||||
|
||||
# - name: Zip artifacts for faster upload
|
||||
# run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/* -DestinationPath archive.zip
|
||||
|
||||
# - name: Save packaged artifacts for signing
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
# path: |
|
||||
# archive.zip
|
||||
# !**/*.map
|
||||
|
||||
# sign-packaged-artifacts-windows:
|
||||
# needs: package-distribution-windows
|
||||
# uses: ./.github/workflows/windows-signer.yml
|
||||
# with:
|
||||
# files: ${{ needs.package-distribution-windows.outputs.FILES_TO_BE_SIGNED }}
|
||||
# artifact-name: packaged-win32-x64
|
||||
|
||||
# make-windows-installer:
|
||||
# needs: sign-packaged-artifacts-windows
|
||||
# strategy:
|
||||
# matrix:
|
||||
# spec:
|
||||
# - runner: windows-latest
|
||||
# platform: win32
|
||||
# arch: x64
|
||||
# target: x86_64-pc-windows-msvc
|
||||
# runs-on: ${{ matrix.spec.runner }}
|
||||
# outputs:
|
||||
# FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - name: Setup Version
|
||||
# id: version
|
||||
# uses: ./.github/actions/setup-version
|
||||
# - name: Setup Node.js
|
||||
# timeout-minutes: 10
|
||||
# uses: ./.github/actions/setup-node
|
||||
# with:
|
||||
# extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
# hard-link-nm: false
|
||||
# nmHoistingLimits: workspaces
|
||||
# - name: Download and overwrite packaged artifacts
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
# path: .
|
||||
# - name: unzip file
|
||||
# run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out
|
||||
|
||||
# - name: Make squirrel.windows installer
|
||||
# run: yarn workspace @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||
|
||||
# - name: Make nsis.windows installer
|
||||
# run: yarn workspace @affine/electron make-nsis --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||
|
||||
# - name: Zip artifacts for faster upload
|
||||
# run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/* -DestinationPath archive.zip
|
||||
|
||||
# - name: get all files to be signed
|
||||
# id: get_files_to_be_signed
|
||||
# run: |
|
||||
# Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\${{ env.BUILD_TYPE }}\make\', '') + '"' }) -join ' ')
|
||||
# "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
|
||||
# echo $FILES_TO_BE_SIGNED
|
||||
|
||||
# - name: Save installer for signing
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
# path: archive.zip
|
||||
|
||||
# sign-installer-artifacts-windows:
|
||||
# needs: make-windows-installer
|
||||
# uses: ./.github/workflows/windows-signer.yml
|
||||
# with:
|
||||
# files: ${{ needs.make-windows-installer.outputs.FILES_TO_BE_SIGNED }}
|
||||
# artifact-name: installer-win32-x64
|
||||
|
||||
# finalize-installer-windows:
|
||||
# needs: [sign-installer-artifacts-windows, before-make]
|
||||
# strategy:
|
||||
# matrix:
|
||||
# spec:
|
||||
# - runner: windows-latest
|
||||
# platform: win32
|
||||
# arch: x64
|
||||
# target: x86_64-pc-windows-msvc
|
||||
# runs-on: ${{ matrix.spec.runner }}
|
||||
# steps:
|
||||
# - name: Download and overwrite installer artifacts
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
# path: .
|
||||
# - name: unzip file
|
||||
# run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make
|
||||
|
||||
# - name: Save artifacts
|
||||
# run: |
|
||||
# mkdir -p builds
|
||||
# mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.zip
|
||||
# mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.exe
|
||||
# mv packages/frontend/electron/out/*/make/nsis.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.nsis.exe
|
||||
|
||||
# - name: Upload Artifact
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
|
||||
# path: builds
|
||||
|
||||
release:
|
||||
needs: [before-make, make-distribution, finalize-installer-windows]
|
||||
needs:
|
||||
- before-make
|
||||
- make-distribution
|
||||
- make-distribution-windows-skip-signing
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://affine.pro">Home Page</a> |
|
||||
<a href="https://discord.gg/whd5mjYqVw">Discord</a> |
|
||||
<a href="https://discord.com/invite/yz6tGVsf5p">Discord</a> |
|
||||
<a href="https://app.affine.pro">Live Demo</a> |
|
||||
<a href="https://affine.pro/blog/">Blog</a> |
|
||||
<a href="https://docs.affine.pro/docs/">Documentation</a>
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.16.0"
|
||||
"version": "0.15.0"
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.16.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -95,7 +95,7 @@
|
||||
"nanoid": "^5.0.7",
|
||||
"nx": "^19.0.0",
|
||||
"nyc": "^17.0.0",
|
||||
"oxlint": "0.7.0",
|
||||
"oxlint": "0.6.1",
|
||||
"prettier": "^3.2.5",
|
||||
"semver": "^7.6.0",
|
||||
"serve": "^14.2.1",
|
||||
@@ -108,7 +108,7 @@
|
||||
"vite-plugin-static-copy": "^1.0.2",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.0"
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/server-native",
|
||||
"version": "0.16.0",
|
||||
"version": "0.15.0",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "_data_migrations" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "started_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "finished_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_messages" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "session_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "doc_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "parent_session_id" SET DATA TYPE VARCHAR;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "app_runtime_settings" ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "last_updated_by" SET DATA TYPE VARCHAR;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "multiple_users_sessions" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshot_histories"
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "guid" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "timestamp" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshots" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "updates" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_connected_accounts" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_features" ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_invoices" ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_sessions" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "session_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_stripe_customers" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "start" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "end" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "next_bill_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "canceled_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "trial_start" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "trial_end" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "name" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "email" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "verification_tokens" ALTER COLUMN "token" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_features" ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_page_user_permissions"
|
||||
ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "page_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_pages" ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "page_id" SET DATA TYPE VARCHAR;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_user_permissions" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "accounts";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "blobs";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "new_features_waiting_list";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "optimized_blobs";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "sessions";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "user_workspace_permissions";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "verificationtokens";
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `snapshot_histories` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "_data_migrations" ALTER COLUMN "started_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "finished_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "app_runtime_settings" ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "multiple_users_sessions" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshot_histories" ALTER COLUMN "timestamp" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshots" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "updates" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_connected_accounts" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_invoices" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_sessions" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_stripe_customers" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ALTER COLUMN "start" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "end" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "next_bill_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "canceled_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "trial_start" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "trial_end" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "email_verified" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "verification_tokens" ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_page_user_permissions" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_user_permissions" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.16.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -11,18 +11,18 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
emailVerifiedAt DateTime? @map("email_verified") @db.Timestamptz(3)
|
||||
name String
|
||||
email String @unique
|
||||
emailVerifiedAt DateTime? @map("email_verified")
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
/// Not available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
/// Indicate whether the user finished the signup progress.
|
||||
/// for example, the value will be false if user never registered and invited into a workspace by others.
|
||||
registered Boolean @default(true)
|
||||
|
||||
features UserFeature[]
|
||||
features UserFeatures[]
|
||||
customer UserStripeCustomer?
|
||||
subscriptions UserSubscription[]
|
||||
invoices UserInvoice[]
|
||||
@@ -38,16 +38,16 @@ model User {
|
||||
}
|
||||
|
||||
model ConnectedAccount {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
provider String @db.VarChar
|
||||
providerAccountId String @map("provider_account_id") @db.VarChar
|
||||
scope String? @db.Text
|
||||
accessToken String? @map("access_token") @db.Text
|
||||
refreshToken String? @map("refresh_token") @db.Text
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -57,9 +57,9 @@ model ConnectedAccount {
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
userSessions UserSession[]
|
||||
|
||||
@@ -67,11 +67,11 @@ model Session {
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
sessionId String @map("session_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
sessionId String @map("session_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -81,10 +81,10 @@ model UserSession {
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
token String @db.VarChar
|
||||
token String @db.VarChar(36)
|
||||
type Int @db.SmallInt
|
||||
credential String? @db.Text
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
expiresAt DateTime @db.Timestamptz(6)
|
||||
|
||||
@@unique([type, token])
|
||||
@@map("verification_tokens")
|
||||
@@ -93,12 +93,12 @@ model VerificationToken {
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
pages WorkspacePage[]
|
||||
permissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
features WorkspaceFeature[]
|
||||
features WorkspaceFeatures[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
@@ -109,8 +109,8 @@ model Workspace {
|
||||
// Only the ones that have ever changed will have records here,
|
||||
// and for others we will make sure it's has a default value return in our bussiness logic.
|
||||
model WorkspacePage {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
pageId String @map("page_id") @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
pageId String @map("page_id") @db.VarChar(36)
|
||||
public Boolean @default(false)
|
||||
// Page/Edgeless
|
||||
mode Int @default(0) @db.SmallInt
|
||||
@@ -121,15 +121,31 @@ model WorkspacePage {
|
||||
@@map("workspace_pages")
|
||||
}
|
||||
|
||||
model WorkspaceUserPermission {
|
||||
// @deprecated, use WorkspaceUserPermission
|
||||
model DeprecatedUserWorkspacePermission {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
subPageId String? @map("sub_page_id") @db.VarChar
|
||||
userId String? @map("entity_id") @db.VarChar
|
||||
/// Read/Write/Admin/Owner
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, subPageId, userId])
|
||||
@@map("user_workspace_permissions")
|
||||
}
|
||||
|
||||
model WorkspaceUserPermission {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
// Read/Write
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
@@ -139,15 +155,15 @@ model WorkspaceUserPermission {
|
||||
}
|
||||
|
||||
model WorkspacePageUserPermission {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
pageId String @map("page_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
pageId String @map("page_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
// Read/Write
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
@@ -160,9 +176,9 @@ model WorkspacePageUserPermission {
|
||||
// for example:
|
||||
// - early access is a feature that allow some users to access the insider version
|
||||
// - pro plan is a quota that allow some users access to more resources after they pay
|
||||
model UserFeature {
|
||||
model UserFeatures {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
featureId Int @map("feature_id") @db.Integer
|
||||
|
||||
// we will record the reason why the feature is enabled/disabled
|
||||
@@ -170,16 +186,16 @@ model UserFeature {
|
||||
// - pro_plan_v1: "user buy the pro plan"
|
||||
reason String @db.VarChar
|
||||
// record the quota enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(6)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
|
||||
activated Boolean @default(false)
|
||||
|
||||
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("user_features")
|
||||
@@ -188,9 +204,9 @@ model UserFeature {
|
||||
// feature gates is a way to enable/disable features for a workspace
|
||||
// for example:
|
||||
// - copilet is a feature that allow some users in a workspace to access the copilet feature
|
||||
model WorkspaceFeature {
|
||||
model WorkspaceFeatures {
|
||||
id Int @id @default(autoincrement())
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
featureId Int @map("feature_id") @db.Integer
|
||||
|
||||
// we will record the reason why the feature is enabled/disabled
|
||||
@@ -198,21 +214,21 @@ model WorkspaceFeature {
|
||||
// - copilet_v1: "owner buy the copilet feature package"
|
||||
reason String @db.VarChar
|
||||
// record the feature enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(6)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it
|
||||
activated Boolean @default(false)
|
||||
|
||||
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("workspace_features")
|
||||
}
|
||||
|
||||
model Feature {
|
||||
model Features {
|
||||
id Int @id @default(autoincrement())
|
||||
feature String @db.VarChar
|
||||
version Int @default(0) @db.Integer
|
||||
@@ -220,15 +236,82 @@ model Feature {
|
||||
type Int @db.Integer
|
||||
// configs, define by feature conntroller
|
||||
configs Json @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
UserFeatureGates UserFeature[]
|
||||
WorkspaceFeatures WorkspaceFeature[]
|
||||
UserFeatureGates UserFeatures[]
|
||||
WorkspaceFeatures WorkspaceFeatures[]
|
||||
|
||||
@@unique([feature, version])
|
||||
@@map("features")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthAccount {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String @map("provider_account_id")
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthSession {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthVerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verificationtokens")
|
||||
}
|
||||
|
||||
// deprecated, use [ObjectStorage]
|
||||
model Blob {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
hash String @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
blob Bytes @db.ByteA
|
||||
length BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// not for keeping, but for snapshot history
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, hash])
|
||||
@@map("blobs")
|
||||
}
|
||||
|
||||
// deprecated, use [ObjectStorage]
|
||||
model OptimizedBlob {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
hash String @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
params String @db.VarChar
|
||||
blob Bytes @db.ByteA
|
||||
length BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// not for keeping, but for snapshot history
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, hash, params])
|
||||
@@map("optimized_blobs")
|
||||
}
|
||||
|
||||
// the latest snapshot of each doc that we've seen
|
||||
// Snapshot + Updates are the latest state of the doc
|
||||
model Snapshot {
|
||||
@@ -237,10 +320,10 @@ model Snapshot {
|
||||
blob Bytes @db.ByteA
|
||||
seq Int @default(0) @db.Integer
|
||||
state Bytes? @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// the `updated_at` field will not record the time of record changed,
|
||||
// but the created time of last seen update that has been merged into snapshot.
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@id([id, workspaceId])
|
||||
@@map("snapshots")
|
||||
@@ -251,28 +334,37 @@ model Update {
|
||||
id String @map("guid") @db.VarChar
|
||||
seq Int @db.Integer
|
||||
blob Bytes @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@id([workspaceId, id, seq])
|
||||
@@map("updates")
|
||||
}
|
||||
|
||||
model SnapshotHistory {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
id String @map("guid") @db.VarChar
|
||||
timestamp DateTime @db.Timestamptz(3)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
id String @map("guid") @db.VarChar(36)
|
||||
timestamp DateTime @db.Timestamptz(6)
|
||||
blob Bytes @db.ByteA
|
||||
state Bytes? @db.ByteA
|
||||
expiredAt DateTime @map("expired_at") @db.Timestamptz(3)
|
||||
expiredAt DateTime @map("expired_at") @db.Timestamptz(6)
|
||||
|
||||
@@id([workspaceId, id, timestamp])
|
||||
@@map("snapshot_histories")
|
||||
}
|
||||
|
||||
model NewFeaturesWaitingList {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
email String @unique
|
||||
type Int @db.SmallInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("new_features_waiting_list")
|
||||
}
|
||||
|
||||
model UserStripeCustomer {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -281,7 +373,7 @@ model UserStripeCustomer {
|
||||
|
||||
model UserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly
|
||||
recurring String @db.VarChar(20)
|
||||
@@ -290,21 +382,21 @@ model UserSubscription {
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
start DateTime @map("start") @db.Timestamptz(3)
|
||||
start DateTime @map("start") @db.Timestamptz(6)
|
||||
// subscription.current_period_end, null for lifetime payment
|
||||
end DateTime? @map("end") @db.Timestamptz(3)
|
||||
end DateTime? @map("end") @db.Timestamptz(6)
|
||||
// subscription.billing_cycle_anchor
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
|
||||
// subscription.canceled_at
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(6)
|
||||
// subscription.trial_start
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(6)
|
||||
// subscription.trial_end
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(6)
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, plan])
|
||||
@@ -313,7 +405,7 @@ model UserSubscription {
|
||||
|
||||
model UserInvoice {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
stripeInvoiceId String @unique @map("stripe_invoice_id")
|
||||
currency String @db.VarChar(3)
|
||||
// CNY 12.50 stored as 1250
|
||||
@@ -321,8 +413,8 @@ model UserInvoice {
|
||||
status String @db.VarChar(20)
|
||||
plan String @db.VarChar(20)
|
||||
recurring String @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
// billing reason
|
||||
reason String @db.VarChar
|
||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||
@@ -350,7 +442,7 @@ model AiPromptMessage {
|
||||
content String @db.Text
|
||||
attachments Json? @db.Json
|
||||
params Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
prompt AiPrompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -366,7 +458,7 @@ model AiPrompt {
|
||||
action String? @db.VarChar
|
||||
model String @db.VarChar
|
||||
config Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
messages AiPromptMessage[]
|
||||
sessions AiSession[]
|
||||
@@ -375,14 +467,14 @@ model AiPrompt {
|
||||
}
|
||||
|
||||
model AiSessionMessage {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
sessionId String @map("session_id") @db.VarChar
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
sessionId String @map("session_id") @db.VarChar(36)
|
||||
role AiPromptRole
|
||||
content String @db.Text
|
||||
attachments Json? @db.Json
|
||||
params Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -390,17 +482,17 @@ model AiSessionMessage {
|
||||
}
|
||||
|
||||
model AiSession {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
docId String @map("doc_id") @db.VarChar(36)
|
||||
promptName String @map("prompt_name") @db.VarChar(32)
|
||||
// the session id of the parent session if this session is a forked session
|
||||
parentSessionId String? @map("parent_session_id") @db.VarChar
|
||||
parentSessionId String? @map("parent_session_id") @db.VarChar(36)
|
||||
messageCost Int @default(0)
|
||||
tokenCost Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade)
|
||||
@@ -410,10 +502,10 @@ model AiSession {
|
||||
}
|
||||
|
||||
model DataMigration {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
name String @db.VarChar
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
@@ -433,9 +525,9 @@ model RuntimeConfig {
|
||||
key String @db.VarChar
|
||||
value Json @db.Json
|
||||
description String @db.Text
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
lastUpdatedBy String? @map("last_updated_by") @db.VarChar
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
lastUpdatedBy String? @map("last_updated_by") @db.VarChar(36)
|
||||
|
||||
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id])
|
||||
|
||||
|
||||
@@ -152,16 +152,14 @@ function buildAppModule() {
|
||||
factor
|
||||
// common fundamental modules
|
||||
.use(...FunctionalityModules)
|
||||
.useIf(config => config.flavor.sync, WebSocketModule)
|
||||
|
||||
// auth
|
||||
.use(UserModule, AuthModule)
|
||||
.use(AuthModule)
|
||||
|
||||
// business modules
|
||||
.use(DocModule)
|
||||
|
||||
// sync server only
|
||||
.useIf(config => config.flavor.sync, SyncModule)
|
||||
.useIf(config => config.flavor.sync, WebSocketModule, SyncModule)
|
||||
|
||||
// graphql server only
|
||||
.useIf(
|
||||
@@ -169,6 +167,7 @@ function buildAppModule() {
|
||||
ServerConfigModule,
|
||||
GqlModule,
|
||||
StorageModule,
|
||||
UserModule,
|
||||
WorkspaceModule,
|
||||
FeatureModule,
|
||||
QuotaModule
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function createApp() {
|
||||
graphqlUploadExpress({
|
||||
// TODO(@darkskygit): dynamic limit by quota maybe?
|
||||
maxFileSize: 100 * 1024 * 1024,
|
||||
maxFiles: 32,
|
||||
maxFiles: 5,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { User, UserSession } from '@prisma/client';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
|
||||
@@ -53,5 +53,3 @@ export interface CurrentUser
|
||||
hasPassword: boolean | null;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export { type UserSession };
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import type {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
FactoryProvider,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
AuthenticationRequired,
|
||||
Config,
|
||||
getRequestResponseFromContext,
|
||||
mapAnyError,
|
||||
parseCookies,
|
||||
} from '../../fundamentals';
|
||||
import { WEBSOCKET_OPTIONS } from '../../fundamentals/websocket';
|
||||
import { CurrentUser, UserSession } from './current-user';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
@@ -45,9 +38,37 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
|
||||
const userSession = await this.signIn(req);
|
||||
if (res && userSession && userSession.session.expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(req, res, userSession.session);
|
||||
// check cookie
|
||||
let sessionToken: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
if (!sessionToken && req.headers.authorization) {
|
||||
sessionToken = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
const userSeq = parseAuthUserSeqNum(
|
||||
req.headers[AuthService.authUserSeqHeaderName]
|
||||
);
|
||||
|
||||
const { user, expiresAt } = await this.auth.getUser(
|
||||
sessionToken,
|
||||
userSeq
|
||||
);
|
||||
if (res && user && expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(
|
||||
req,
|
||||
res,
|
||||
sessionToken,
|
||||
user.id,
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
req.sid = sessionToken;
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
// api is public
|
||||
@@ -63,44 +84,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async signIn(
|
||||
req: Request
|
||||
): Promise<{ user: CurrentUser; session: UserSession } | null> {
|
||||
if (req.user && req.session) {
|
||||
return {
|
||||
user: req.user,
|
||||
session: req.session,
|
||||
};
|
||||
}
|
||||
|
||||
parseCookies(req);
|
||||
let sessionToken: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
if (!sessionToken && req.headers.authorization) {
|
||||
sessionToken = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
const userSeq = parseAuthUserSeqNum(
|
||||
req.headers[AuthService.authUserSeqHeaderName]
|
||||
);
|
||||
|
||||
const userSession = await this.auth.getUserSession(sessionToken, userSeq);
|
||||
|
||||
if (userSession) {
|
||||
req.session = userSession.session;
|
||||
req.user = userSession.user;
|
||||
}
|
||||
|
||||
return userSession;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,35 +111,3 @@ export const Auth = () => {
|
||||
|
||||
// api is public accessible
|
||||
export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true);
|
||||
|
||||
export const AuthWebsocketOptionsProvider: FactoryProvider = {
|
||||
provide: WEBSOCKET_OPTIONS,
|
||||
useFactory: (config: Config, guard: AuthGuard) => {
|
||||
return {
|
||||
...config.websocket,
|
||||
allowRequest: async (
|
||||
req: any,
|
||||
pass: (err: string | null | undefined, success: boolean) => void
|
||||
) => {
|
||||
if (!config.websocket.requireAuthentication) {
|
||||
return pass(null, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const authentication = await guard.signIn(req);
|
||||
|
||||
if (authentication) {
|
||||
return pass(null, true);
|
||||
} else {
|
||||
return pass('unauthenticated', false);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = mapAnyError(e);
|
||||
error.log('Websocket');
|
||||
return pass('unauthenticated', false);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [Config, AuthGuard],
|
||||
};
|
||||
|
||||
@@ -6,21 +6,15 @@ import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { UserModule } from '../user';
|
||||
import { AuthController } from './controller';
|
||||
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
|
||||
import { AuthGuard } from './guard';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
@Module({
|
||||
imports: [FeatureModule, UserModule, QuotaModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthResolver,
|
||||
TokenService,
|
||||
AuthGuard,
|
||||
AuthWebsocketOptionsProvider,
|
||||
],
|
||||
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
|
||||
providers: [AuthService, AuthResolver, TokenService, AuthGuard],
|
||||
exports: [AuthService, AuthGuard],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Throttle,
|
||||
URLHelper,
|
||||
} from '../../fundamentals';
|
||||
import { Admin } from '../common';
|
||||
import { UserService } from '../user';
|
||||
import { UserType } from '../user/types';
|
||||
import { validators } from '../utils/validators';
|
||||
@@ -292,19 +291,4 @@ export class AuthResolver {
|
||||
|
||||
return emailVerifiedAt !== null;
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => String, {
|
||||
description: 'Create change password url',
|
||||
})
|
||||
async createChangePasswordUrl(
|
||||
@Args('userId') userId: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
): Promise<string> {
|
||||
const token = await this.token.createToken(
|
||||
TokenType.ChangePassword,
|
||||
userId
|
||||
);
|
||||
return this.url.link(callbackUrl, { token });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import type { User, UserSession } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
|
||||
import { Config, EmailAlreadyUsed, MailService } from '../../fundamentals';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
@@ -41,11 +41,13 @@ export function sessionUser(
|
||||
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
|
||||
> & { password?: string | null }
|
||||
): CurrentUser {
|
||||
// use pick to avoid unexpected fields
|
||||
return assign(pick(user, 'id', 'email', 'avatarUrl', 'name'), {
|
||||
hasPassword: user.password !== null,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
});
|
||||
return assign(
|
||||
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'),
|
||||
{
|
||||
hasPassword: user.password !== null,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -119,27 +121,27 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
async getUserSession(
|
||||
async getUser(
|
||||
token: string,
|
||||
seq = 0
|
||||
): Promise<{ user: CurrentUser; session: UserSession } | null> {
|
||||
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
// no such session
|
||||
if (!session) {
|
||||
return null;
|
||||
return { user: null, expiresAt: null };
|
||||
}
|
||||
|
||||
const userSession = session.userSessions.at(seq);
|
||||
|
||||
// no such user session
|
||||
if (!userSession) {
|
||||
return null;
|
||||
return { user: null, expiresAt: null };
|
||||
}
|
||||
|
||||
// user session expired
|
||||
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
|
||||
return null;
|
||||
return { user: null, expiresAt: null };
|
||||
}
|
||||
|
||||
const user = await this.db.user.findUnique({
|
||||
@@ -147,10 +149,10 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
return { user: null, expiresAt: null };
|
||||
}
|
||||
|
||||
return { user: sessionUser(user), session: userSession };
|
||||
return { user: sessionUser(user), expiresAt: userSession.expiresAt };
|
||||
}
|
||||
|
||||
async getUserList(token: string) {
|
||||
@@ -249,13 +251,12 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
async refreshUserSessionIfNeeded(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
session: UserSession,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
expiresAt: Date,
|
||||
ttr = this.config.auth.session.ttr
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
session.expiresAt &&
|
||||
session.expiresAt.getTime() - Date.now() > ttr * 1000
|
||||
) {
|
||||
if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) {
|
||||
// no need to refresh
|
||||
return false;
|
||||
}
|
||||
@@ -266,14 +267,17 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
|
||||
await this.db.userSession.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
sessionId_userId: {
|
||||
sessionId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
expiresAt: newExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, session.sessionId, {
|
||||
res.cookie(AuthService.sessionCookieName, sessionId, {
|
||||
expires: newExpiresAt,
|
||||
...this.cookieOptions,
|
||||
});
|
||||
|
||||
@@ -16,5 +16,5 @@ import {
|
||||
],
|
||||
})
|
||||
export class ServerConfigModule {}
|
||||
export { ADD_ENABLED_FEATURES } from './server-feature';
|
||||
export { ADD_ENABLED_FEATURES, ServerConfigType } from './resolver';
|
||||
export { ServerFeature } from './types';
|
||||
|
||||
@@ -12,14 +12,24 @@ import {
|
||||
import { PrismaClient, RuntimeConfig, RuntimeConfigType } from '@prisma/client';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, URLHelper } from '../../fundamentals';
|
||||
import { Config, DeploymentType, URLHelper } from '../../fundamentals';
|
||||
import { Public } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { FeatureType } from '../features';
|
||||
import { AvailableUserFeatureConfig } from '../features/resolver';
|
||||
import { ServerFlags } from './config';
|
||||
import { ENABLED_FEATURES } from './server-feature';
|
||||
import { ServerConfigType } from './types';
|
||||
import { ServerFeature } from './types';
|
||||
|
||||
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class PasswordLimitsType {
|
||||
@@ -35,6 +45,36 @@ export class CredentialsRequirementType {
|
||||
password!: PasswordLimitsType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
@Field({
|
||||
description:
|
||||
'server identical name could be shown as badge on user interface',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'server version' })
|
||||
version!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field({ description: 'enable telemetry' })
|
||||
enableTelemetry!: boolean;
|
||||
}
|
||||
|
||||
registerEnumType(RuntimeConfigType, {
|
||||
name: 'RuntimeConfigType',
|
||||
});
|
||||
@@ -135,20 +175,6 @@ export class ServerConfigResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => ServerConfigType)
|
||||
export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
description: 'Features for user that can be configured',
|
||||
})
|
||||
override availableUserFeatures() {
|
||||
return super.availableUserFeatures();
|
||||
}
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class ServerServiceConfig {
|
||||
@Field()
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ServerFeature } from './types';
|
||||
|
||||
export const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
export { ServerFeature };
|
||||
@@ -1,47 +1,5 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../../fundamentals';
|
||||
|
||||
export enum ServerFeature {
|
||||
Copilot = 'copilot',
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
@Field({
|
||||
description:
|
||||
'server identical name could be shown as badge on user interface',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'server version' })
|
||||
version!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field({ description: 'enable telemetry' })
|
||||
enableTelemetry!: boolean;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function getFeature(prisma: PrismaTransaction, featureId: number) {
|
||||
return cachedFeature;
|
||||
}
|
||||
|
||||
const feature = await prisma.feature.findFirst({
|
||||
const feature = await prisma.features.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserModule } from '../user';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import {
|
||||
AdminFeatureManagementResolver,
|
||||
FeatureManagementResolver,
|
||||
} from './resolver';
|
||||
import { FeatureManagementResolver } from './resolver';
|
||||
import { FeatureService } from './service';
|
||||
|
||||
/**
|
||||
@@ -20,7 +17,6 @@ import { FeatureService } from './service';
|
||||
FeatureService,
|
||||
FeatureManagementService,
|
||||
FeatureManagementResolver,
|
||||
AdminFeatureManagementResolver,
|
||||
],
|
||||
exports: [FeatureService, FeatureManagementService],
|
||||
})
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import { Config } from '../../fundamentals';
|
||||
import { UserNotFound } from '../../fundamentals';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { Admin } from '../common';
|
||||
import { UserService } from '../user/service';
|
||||
import { UserType } from '../user/types';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
registerEnumType(EarlyAccessType, {
|
||||
@@ -21,7 +24,10 @@ registerEnumType(EarlyAccessType, {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class FeatureManagementResolver {
|
||||
constructor(private readonly feature: FeatureManagementService) {}
|
||||
constructor(
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
name: 'features',
|
||||
@@ -30,48 +36,58 @@ export class FeatureManagementResolver {
|
||||
async userFeatures(@Parent() user: UserType) {
|
||||
return this.feature.getActivatedUserFeatures(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class AvailableUserFeatureConfig {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async availableUserFeatures() {
|
||||
return this.config.isSelfhosted
|
||||
? [FeatureType.Admin]
|
||||
: [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin];
|
||||
}
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Resolver(() => Boolean)
|
||||
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly feature: FeatureService
|
||||
) {
|
||||
super(config);
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@Args('email') email: string,
|
||||
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType
|
||||
): Promise<number> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id, type);
|
||||
} else {
|
||||
const user = await this.users.createUser({
|
||||
email,
|
||||
registered: false,
|
||||
});
|
||||
return this.feature.addEarlyAccess(user.id, type);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => [FeatureType], {
|
||||
description: 'update user enabled feature',
|
||||
})
|
||||
async updateUserFeatures(
|
||||
@Args('id') id: string,
|
||||
@Args({ name: 'features', type: () => [FeatureType] })
|
||||
features: FeatureType[]
|
||||
) {
|
||||
const configurableFeatures = await this.availableUserFeatures();
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
return this.feature.removeEarlyAccess(user.id);
|
||||
}
|
||||
|
||||
const removed = difference(configurableFeatures, features);
|
||||
await Promise.all(
|
||||
features.map(feature =>
|
||||
this.feature.addUserFeature(id, feature, 'admin panel')
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
removed.map(feature => this.feature.removeUserFeature(id, feature))
|
||||
);
|
||||
@Admin()
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean }
|
||||
): Promise<UserType[]> {
|
||||
// allow query other user's subscription
|
||||
ctx.isAdminQuery = true;
|
||||
return this.feature.listEarlyAccess().then(users => {
|
||||
return users.map(sessionUser);
|
||||
});
|
||||
}
|
||||
|
||||
return features;
|
||||
@Admin()
|
||||
@Mutation(() => Boolean)
|
||||
async addAdminister(@Args('email') email: string): Promise<boolean> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
await this.feature.addAdmin(user.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { CannotDeleteAllAdminAccount } from '../../fundamentals';
|
||||
import { WorkspaceType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
@@ -11,7 +10,7 @@ export class FeatureService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async getFeature<F extends FeatureType>(feature: F) {
|
||||
const data = await this.prisma.feature.findFirst({
|
||||
const data = await this.prisma.features.findFirst({
|
||||
where: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
@@ -37,7 +36,7 @@ export class FeatureService {
|
||||
expiredAt?: Date | string
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeature.findFirst({
|
||||
const latestFlag = await tx.userFeatures.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
@@ -54,7 +53,7 @@ export class FeatureService {
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
const featureId = await tx.feature
|
||||
const featureId = await tx.features
|
||||
.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
orderBy: { version: 'desc' },
|
||||
@@ -66,7 +65,7 @@ export class FeatureService {
|
||||
throw new Error(`Feature ${feature} not found`);
|
||||
}
|
||||
|
||||
return tx.userFeature
|
||||
return tx.userFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
@@ -82,10 +81,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async removeUserFeature(userId: string, feature: FeatureType) {
|
||||
if (feature === FeatureType.Admin) {
|
||||
await this.ensureNotLastAdmin(userId);
|
||||
}
|
||||
return this.prisma.userFeature
|
||||
return this.prisma.userFeatures
|
||||
.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -102,27 +98,13 @@ export class FeatureService {
|
||||
.then(r => r.count);
|
||||
}
|
||||
|
||||
async ensureNotLastAdmin(userId: string) {
|
||||
const count = await this.prisma.userFeature.count({
|
||||
where: {
|
||||
userId: { not: userId },
|
||||
feature: { feature: FeatureType.Admin, type: FeatureKind.Feature },
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new CannotDeleteAllAdminAccount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get user's features, will included inactivated features
|
||||
* @param userId user id
|
||||
* @returns list of features
|
||||
*/
|
||||
async getUserFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
const features = await this.prisma.userFeatures.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Feature },
|
||||
@@ -147,7 +129,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async getActivatedUserFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
const features = await this.prisma.userFeatures.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Feature },
|
||||
@@ -174,7 +156,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType) {
|
||||
return this.prisma.userFeature
|
||||
return this.prisma.userFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
@@ -200,7 +182,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async hasUserFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeature
|
||||
return this.prisma.userFeatures
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
@@ -224,7 +206,7 @@ export class FeatureService {
|
||||
expiredAt?: Date | string
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.workspaceFeature.findFirst({
|
||||
const latestFlag = await tx.workspaceFeatures.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: {
|
||||
@@ -241,7 +223,7 @@ export class FeatureService {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
// use latest version of feature
|
||||
const featureId = await tx.feature
|
||||
const featureId = await tx.features
|
||||
.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
select: { id: true },
|
||||
@@ -253,7 +235,7 @@ export class FeatureService {
|
||||
throw new Error(`Feature ${feature} not found`);
|
||||
}
|
||||
|
||||
return tx.workspaceFeature
|
||||
return tx.workspaceFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
@@ -269,7 +251,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeature
|
||||
return this.prisma.workspaceFeatures
|
||||
.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
@@ -292,7 +274,7 @@ export class FeatureService {
|
||||
* @returns list of features
|
||||
*/
|
||||
async getWorkspaceFeatures(workspaceId: string) {
|
||||
const features = await this.prisma.workspaceFeature.findMany({
|
||||
const features = await this.prisma.workspaceFeatures.findMany({
|
||||
where: {
|
||||
workspace: { id: workspaceId },
|
||||
feature: {
|
||||
@@ -319,7 +301,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
||||
return this.prisma.workspaceFeature
|
||||
return this.prisma.workspaceFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
@@ -342,7 +324,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeature
|
||||
return this.prisma.workspaceFeatures
|
||||
.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
|
||||
@@ -13,7 +13,7 @@ export class QuotaConfig {
|
||||
return cachedQuota;
|
||||
}
|
||||
|
||||
const quota = await tx.feature.findFirst({
|
||||
const quota = await tx.features.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ export class QuotaService {
|
||||
|
||||
// get activated user quota
|
||||
async getUserQuota(userId: string) {
|
||||
const quota = await this.prisma.userFeature.findFirst({
|
||||
const quota = await this.prisma.userFeatures.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
@@ -44,7 +44,7 @@ export class QuotaService {
|
||||
|
||||
// get user all quota records
|
||||
async getUserQuotas(userId: string) {
|
||||
const quotas = await this.prisma.userFeature.findMany({
|
||||
const quotas = await this.prisma.userFeatures.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
@@ -58,9 +58,6 @@ export class QuotaService {
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
const configs = await Promise.all(
|
||||
quotas.map(async quota => {
|
||||
@@ -95,7 +92,7 @@ export class QuotaService {
|
||||
return;
|
||||
}
|
||||
|
||||
const featureId = await tx.feature
|
||||
const featureId = await tx.features
|
||||
.findFirst({
|
||||
where: { feature: quota, type: FeatureKind.Quota },
|
||||
select: { id: true },
|
||||
@@ -108,7 +105,7 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
// we will deactivate all exists quota for this user
|
||||
await tx.userFeature.updateMany({
|
||||
await tx.userFeatures.updateMany({
|
||||
where: {
|
||||
id: undefined,
|
||||
userId,
|
||||
@@ -121,7 +118,7 @@ export class QuotaService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userFeature.create({
|
||||
await tx.userFeatures.create({
|
||||
data: {
|
||||
userId,
|
||||
featureId,
|
||||
@@ -136,7 +133,7 @@ export class QuotaService {
|
||||
async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
||||
const executor = tx ?? this.prisma;
|
||||
|
||||
return executor.userFeature
|
||||
return executor.userFeatures
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@@ -50,7 +50,12 @@ function Awareness(workspaceId: string): `${string}:awareness` {
|
||||
return `${workspaceId}:awareness`;
|
||||
}
|
||||
|
||||
@WebSocketGateway()
|
||||
@WebSocketGateway({
|
||||
cors: !AFFiNE.node.prod,
|
||||
transports: ['websocket'],
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
})
|
||||
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
protected logger = new Logger(EventsGateway.name);
|
||||
private connectionCount = 0;
|
||||
|
||||
@@ -12,12 +12,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { isNil, omitBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
type FileUpload,
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../fundamentals';
|
||||
import { type FileUpload, Throttle, UserNotFound } from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
@@ -27,7 +22,6 @@ import { validators } from '../utils/validators';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
ManageUserInput,
|
||||
RemoveAvatar,
|
||||
UpdateUserInput,
|
||||
UserOrLimitedUser,
|
||||
@@ -167,6 +161,9 @@ class CreateUserInput {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
password!: string | null;
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@@ -177,13 +174,6 @@ export class UserManagementResolver {
|
||||
private readonly user: UserService
|
||||
) {}
|
||||
|
||||
@Query(() => Int, {
|
||||
description: 'Get users count',
|
||||
})
|
||||
async usersCount(): Promise<number> {
|
||||
return this.db.user.count();
|
||||
}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
description: 'List registered users',
|
||||
})
|
||||
@@ -218,26 +208,6 @@ export class UserManagementResolver {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'userByEmail',
|
||||
description: 'Get user by email for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async getUserByEmail(@Args('email') email: string) {
|
||||
const user = await this.db.user.findUnique({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
description: 'Create a new user',
|
||||
})
|
||||
@@ -246,6 +216,7 @@ export class UserManagementResolver {
|
||||
) {
|
||||
const { id } = await this.user.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
registered: true,
|
||||
});
|
||||
|
||||
@@ -256,42 +227,8 @@ export class UserManagementResolver {
|
||||
@Mutation(() => DeleteAccount, {
|
||||
description: 'Delete a user account',
|
||||
})
|
||||
async deleteUser(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<DeleteAccount> {
|
||||
if (user.id === id) {
|
||||
throw new CannotDeleteOwnAccount();
|
||||
}
|
||||
async deleteUser(@Args('id') id: string): Promise<DeleteAccount> {
|
||||
await this.user.deleteUser(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
description: 'Update a user',
|
||||
})
|
||||
async updateUser(
|
||||
@Args('id') id: string,
|
||||
@Args('input') input: ManageUserInput
|
||||
): Promise<UserType> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
input = omitBy(input, isNil);
|
||||
if (Object.keys(input).length === 0) {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.user.updateUser(user.id, {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,9 @@ export class UserService {
|
||||
|
||||
async updateUser(
|
||||
id: string,
|
||||
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>,
|
||||
data: Omit<Prisma.UserUpdateInput, 'password'> & {
|
||||
password?: string | null;
|
||||
},
|
||||
select: Prisma.UserSelect = this.defaultUserSelect
|
||||
) {
|
||||
if (data.password) {
|
||||
@@ -209,23 +211,6 @@ export class UserService {
|
||||
|
||||
data.password = await this.crypto.encryptPassword(data.password);
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
validators.assertValidEmail(data.email);
|
||||
const emailTaken = await this.prisma.user.count({
|
||||
where: {
|
||||
email: data.email,
|
||||
id: {
|
||||
not: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (emailTaken) {
|
||||
throw new EmailAlreadyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({ where: { id }, data, select });
|
||||
|
||||
this.emitter.emit('user.updated', user);
|
||||
|
||||
@@ -83,15 +83,6 @@ export class UpdateUserInput implements Partial<User> {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class ManageUserInput {
|
||||
@Field({ description: 'User email', nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Field({ description: 'User name', nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
|
||||
declare module '../../fundamentals/event/def' {
|
||||
interface UserEvents {
|
||||
admin: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Features } from '../../core/features';
|
||||
import { Quotas } from '../../core/quota/schema';
|
||||
import { upsertFeature } from './utils/user-features';
|
||||
import { migrateNewFeatureTable, upsertFeature } from './utils/user-features';
|
||||
|
||||
export class UserFeaturesInit1698652531198 {
|
||||
// do the migration
|
||||
@@ -11,6 +11,7 @@ export class UserFeaturesInit1698652531198 {
|
||||
for (const feature of Features) {
|
||||
await upsertFeature(db, feature);
|
||||
}
|
||||
await migrateNewFeatureTable(db);
|
||||
|
||||
for (const quota of Quotas) {
|
||||
await upsertFeature(db, quota);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
export class PagePermission1699005339766 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
let turn = 0;
|
||||
let lastTurnCount = 50;
|
||||
const done = new Set<string>();
|
||||
|
||||
while (lastTurnCount === 50) {
|
||||
const workspaces = await db.workspace.findMany({
|
||||
skip: turn * 50,
|
||||
take: 50,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
lastTurnCount = workspaces.length;
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
if (done.has(workspace.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldPermissions =
|
||||
await db.deprecatedUserWorkspacePermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (const oldPermission of oldPermissions) {
|
||||
// mark subpage public
|
||||
if (oldPermission.subPageId) {
|
||||
const existed = await db.workspacePage.findUnique({
|
||||
where: {
|
||||
workspaceId_pageId: {
|
||||
workspaceId: oldPermission.workspaceId,
|
||||
pageId: oldPermission.subPageId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existed) {
|
||||
await db.workspacePage.create({
|
||||
select: null,
|
||||
data: {
|
||||
workspaceId: oldPermission.workspaceId,
|
||||
pageId: oldPermission.subPageId,
|
||||
public: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (oldPermission.userId) {
|
||||
// workspace user permission
|
||||
const existed = await db.workspaceUserPermission.findUnique({
|
||||
where: {
|
||||
id: oldPermission.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existed) {
|
||||
await db.workspaceUserPermission
|
||||
.create({
|
||||
select: null,
|
||||
data: {
|
||||
// this id is used at invite email, should keep
|
||||
id: oldPermission.id,
|
||||
workspaceId: oldPermission.workspaceId,
|
||||
userId: oldPermission.userId,
|
||||
type: oldPermission.type,
|
||||
accepted: oldPermission.accepted,
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// duplicated
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// ignore wrong data
|
||||
}
|
||||
}
|
||||
|
||||
done.add(workspace.id);
|
||||
}
|
||||
|
||||
turn++;
|
||||
}
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(db: PrismaClient) {
|
||||
await db.workspaceUserPermission.deleteMany({});
|
||||
await db.workspacePageUserPermission.deleteMany({});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export class OldUserFeature1702620653283 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
await db.$transaction(async tx => {
|
||||
const latestFreePlan = await tx.feature.findFirstOrThrow({
|
||||
const latestFreePlan = await tx.features.findFirstOrThrow({
|
||||
where: { feature: QuotaType.FreePlanV1 },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { id: true },
|
||||
@@ -17,7 +17,7 @@ export class OldUserFeature1702620653283 {
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.userFeature.createMany({
|
||||
await tx.userFeatures.createMany({
|
||||
data: userIds.map(({ id: userId }) => ({
|
||||
userId,
|
||||
featureId: latestFreePlan.id,
|
||||
@@ -31,6 +31,6 @@ export class OldUserFeature1702620653283 {
|
||||
// revert the migration
|
||||
// WARN: this will drop all user features
|
||||
static async down(db: PrismaClient) {
|
||||
await db.userFeature.deleteMany({});
|
||||
await db.userFeatures.deleteMany({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||
|
||||
export class WorkspaceBlobs1703828796699 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient, injector: ModuleRef) {
|
||||
const blobStorage = injector.get(WorkspaceBlobStorage, { strict: false });
|
||||
let hasMore = true;
|
||||
let turn = 0;
|
||||
const eachTurnCount = 50;
|
||||
|
||||
while (hasMore) {
|
||||
const blobs = await db.blob.findMany({
|
||||
skip: turn * eachTurnCount,
|
||||
take: eachTurnCount,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
hasMore = blobs.length === eachTurnCount;
|
||||
turn += 1;
|
||||
|
||||
await Promise.all(
|
||||
blobs.map(async ({ workspaceId, hash, blob }) =>
|
||||
blobStorage.put(workspaceId, hash, blob)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {
|
||||
// old data kept, no need to downgrade the migration
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { loop } from './utils/loop';
|
||||
|
||||
export class Oauth1710319359062 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
await loop(async (skip, take) => {
|
||||
const oldRecords = await db.deprecatedNextAuthAccount.findMany({
|
||||
skip,
|
||||
take,
|
||||
orderBy: {
|
||||
providerAccountId: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
await db.connectedAccount.createMany({
|
||||
data: oldRecords.map(record => ({
|
||||
userId: record.userId,
|
||||
provider: record.provider,
|
||||
scope: record.scope,
|
||||
providerAccountId: record.providerAccountId,
|
||||
accessToken: record.access_token,
|
||||
refreshToken: record.refresh_token,
|
||||
expiresAt: record.expires_at
|
||||
? new Date(record.expires_at * 1000)
|
||||
: null,
|
||||
})),
|
||||
});
|
||||
|
||||
return oldRecords.length;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(db: PrismaClient) {
|
||||
await db.connectedAccount.deleteMany({});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { CommonFeature, Features, FeatureType } from '../../../core/features';
|
||||
import {
|
||||
CommonFeature,
|
||||
FeatureKind,
|
||||
Features,
|
||||
FeatureType,
|
||||
} from '../../../core/features';
|
||||
|
||||
// upgrade features from lower version to higher version
|
||||
export async function upsertFeature(
|
||||
@@ -8,7 +13,7 @@ export async function upsertFeature(
|
||||
feature: CommonFeature
|
||||
): Promise<void> {
|
||||
const hasEqualOrGreaterVersion =
|
||||
(await db.feature.count({
|
||||
(await db.features.count({
|
||||
where: {
|
||||
feature: feature.feature,
|
||||
version: {
|
||||
@@ -18,7 +23,7 @@ export async function upsertFeature(
|
||||
})) > 0;
|
||||
// will not update exists version
|
||||
if (!hasEqualOrGreaterVersion) {
|
||||
await db.feature.create({
|
||||
await db.features.create({
|
||||
data: {
|
||||
feature: feature.feature,
|
||||
type: feature.type,
|
||||
@@ -38,3 +43,66 @@ export async function upsertLatestFeatureVersion(
|
||||
const latestFeature = feature[0];
|
||||
await upsertFeature(db, latestFeature);
|
||||
}
|
||||
|
||||
export async function migrateNewFeatureTable(prisma: PrismaClient) {
|
||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||
const latestEarlyAccessFeatureId = await prisma.features
|
||||
.findFirst({
|
||||
where: { feature: FeatureType.EarlyAccess, type: FeatureKind.Feature },
|
||||
select: { id: true },
|
||||
orderBy: { version: 'desc' },
|
||||
})
|
||||
.then(r => r?.id);
|
||||
if (!latestEarlyAccessFeatureId) {
|
||||
throw new Error('Feature EarlyAccess not found');
|
||||
}
|
||||
for (const oldUser of waitingList) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: oldUser.email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const hasEarlyAccess = await prisma.userFeatures.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
if (hasEarlyAccess === 0) {
|
||||
await prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeatures.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
return tx.userFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason: 'Early access user',
|
||||
activated: true,
|
||||
userId: user.id,
|
||||
featureId: latestEarlyAccessFeatureId,
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function upgradeQuotaVersion(
|
||||
// migrate all users that using old quota to new quota
|
||||
await db.$transaction(
|
||||
async tx => {
|
||||
const latestQuotaVersion = await tx.feature.findFirstOrThrow({
|
||||
const latestQuotaVersion = await tx.features.findFirstOrThrow({
|
||||
where: { feature: quota.feature },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { id: true },
|
||||
@@ -39,7 +39,7 @@ export async function upgradeQuotaVersion(
|
||||
});
|
||||
|
||||
// deactivate all old quota for the user
|
||||
await tx.userFeature.updateMany({
|
||||
await tx.userFeatures.updateMany({
|
||||
where: {
|
||||
id: undefined,
|
||||
userId: {
|
||||
@@ -55,7 +55,7 @@ export async function upgradeQuotaVersion(
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userFeature.createMany({
|
||||
await tx.userFeatures.createMany({
|
||||
data: userIds.map(({ id: userId }) => ({
|
||||
userId,
|
||||
featureId: latestQuotaVersion.id,
|
||||
|
||||
@@ -498,12 +498,4 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
type: 'internal_server_error',
|
||||
message: 'Mailer service is not configured.',
|
||||
},
|
||||
cannot_delete_all_admin_account: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Cannot delete all admin accounts.',
|
||||
},
|
||||
cannot_delete_own_account: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Cannot delete own account.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
||||
@@ -487,18 +487,6 @@ export class MailerServiceIsNotConfigured extends UserFriendlyError {
|
||||
super('internal_server_error', 'mailer_service_is_not_configured', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotDeleteAllAdminAccount extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cannot_delete_all_admin_account', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotDeleteOwnAccount extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cannot_delete_own_account', message);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
TOO_MANY_REQUEST,
|
||||
@@ -563,9 +551,7 @@ export enum ErrorNames {
|
||||
COPILOT_QUOTA_EXCEEDED,
|
||||
RUNTIME_CONFIG_NOT_FOUND,
|
||||
INVALID_RUNTIME_CONFIG_TYPE,
|
||||
MAILER_SERVICE_IS_NOT_CONFIGURED,
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
|
||||
CANNOT_DELETE_OWN_ACCOUNT
|
||||
MAILER_SERVICE_IS_NOT_CONFIGURED
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
|
||||
@@ -36,6 +36,5 @@ export {
|
||||
getRequestFromHost,
|
||||
getRequestResponseFromContext,
|
||||
getRequestResponseFromHost,
|
||||
parseCookies,
|
||||
} from './utils/request';
|
||||
export type * from './utils/types';
|
||||
|
||||
@@ -148,7 +148,7 @@ export const emailTemplate = ({
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/whd5mjYqVw" target="_blank"
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
|
||||
@@ -2,10 +2,8 @@ import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
|
||||
import { BaseExceptionFilter } from '@nestjs/core';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { ThrottlerException } from '@nestjs/throttler';
|
||||
import { BaseWsExceptionFilter } from '@nestjs/websockets';
|
||||
import { Response } from 'express';
|
||||
import { of } from 'rxjs';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
@@ -46,20 +44,6 @@ export class GlobalExceptionFilter extends BaseExceptionFilter {
|
||||
}
|
||||
}
|
||||
|
||||
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
|
||||
// @ts-expect-error satisfies the override
|
||||
override handleError(client: Socket, exception: any): void {
|
||||
const error = mapAnyError(exception);
|
||||
error.log('Websocket');
|
||||
metrics.socketio
|
||||
.counter('unhandled_error')
|
||||
.add(1, { status: error.status });
|
||||
client.emit('error', {
|
||||
error: toWebsocketError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only exists for websocket error body backward compatibility
|
||||
*
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
|
||||
override getTracker(req: Request): Promise<string> {
|
||||
return Promise.resolve(
|
||||
// ↓ prefer session id if available
|
||||
`throttler:${req.session?.sessionId ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
|
||||
`throttler:${req.sid ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
|
||||
// ^ throttler prefix make the key in store recognizable
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,29 +66,3 @@ export function getRequestFromHost(host: ArgumentsHost) {
|
||||
export function getRequestResponseFromContext(ctx: ExecutionContext) {
|
||||
return getRequestResponseFromHost(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* simple patch for request not protected by `cookie-parser`
|
||||
* only take effect if `req.cookies` is not defined
|
||||
*/
|
||||
export function parseCookies(req: Request) {
|
||||
if (req.cookies) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cookieStr = req?.headers?.cookie ?? '';
|
||||
req.cookies = cookieStr.split(';').reduce(
|
||||
(cookies, cookie) => {
|
||||
const [key, val] = cookie.split('=');
|
||||
|
||||
if (key) {
|
||||
cookies[decodeURIComponent(key.trim())] = val
|
||||
? decodeURIComponent(val.trim())
|
||||
: val;
|
||||
}
|
||||
|
||||
return cookies;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { GatewayMetadata } from '@nestjs/websockets';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
websocket: ModuleConfig<
|
||||
GatewayMetadata & {
|
||||
requireAuthentication?: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('websocket', {
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
transports: ['websocket'],
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
requireAuthentication: true,
|
||||
});
|
||||
@@ -1,46 +1,17 @@
|
||||
import './config';
|
||||
|
||||
import {
|
||||
FactoryProvider,
|
||||
INestApplicationContext,
|
||||
Module,
|
||||
Provider,
|
||||
} from '@nestjs/common';
|
||||
import { Module, Provider } from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
|
||||
|
||||
export class SocketIoAdapter extends IoAdapter {
|
||||
constructor(protected readonly app: INestApplicationContext) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: any): Server {
|
||||
const config = this.app.get(WEBSOCKET_OPTIONS);
|
||||
return super.createIOServer(port, { ...config, ...options });
|
||||
}
|
||||
}
|
||||
export class SocketIoAdapter extends IoAdapter {}
|
||||
|
||||
const SocketIoAdapterImplProvider: Provider = {
|
||||
provide: SocketIoAdapterImpl,
|
||||
useValue: SocketIoAdapter,
|
||||
};
|
||||
|
||||
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
|
||||
|
||||
export const websocketOptionsProvider: FactoryProvider = {
|
||||
provide: WEBSOCKET_OPTIONS,
|
||||
useFactory: (config: Config) => {
|
||||
return config.websocket;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
|
||||
@Module({
|
||||
providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
|
||||
exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
|
||||
providers: [SocketIoAdapterImplProvider],
|
||||
exports: [SocketIoAdapterImplProvider],
|
||||
})
|
||||
export class WebSocketModule {}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
user?: import('./core/auth/current-user').CurrentUser;
|
||||
session?: import('./core/auth/current-user').UserSession;
|
||||
sid?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,18 @@ const workflows: Prompt[] = [
|
||||
];
|
||||
|
||||
const actions: Prompt[] = [
|
||||
{
|
||||
name: 'debug:action:gpt4',
|
||||
action: 'text',
|
||||
model: 'gpt-4o',
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
name: 'debug:action:vision4',
|
||||
action: 'text',
|
||||
model: 'gpt-4o',
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
name: 'debug:action:dalle3',
|
||||
action: 'image',
|
||||
@@ -290,6 +302,12 @@ const actions: Prompt[] = [
|
||||
model: 'lcm-sd15-i2i',
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
name: 'debug:action:fal-sdturbo',
|
||||
action: 'image',
|
||||
model: 'fast-turbo-diffusion',
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
name: 'debug:action:fal-upscaler',
|
||||
action: 'Clearer',
|
||||
@@ -314,14 +332,14 @@ const actions: Prompt[] = [
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
name: 'Generate a caption',
|
||||
name: 'debug:action:fal-summary-caption',
|
||||
action: 'Generate a caption',
|
||||
model: 'gpt-4o-mini',
|
||||
model: 'llava-next',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Please understand this image and generate a short caption that can summarize the content of the image. Limit it to up 20 words. {{content}}',
|
||||
'Please understand this image and generate a short caption. Limit it to 20 words. {{content}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -375,7 +393,7 @@ content: {{content}}`,
|
||||
{
|
||||
name: 'Explain this image',
|
||||
action: 'Explain this image',
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-4-vision-preview',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -674,7 +692,7 @@ content: {{content}}`,
|
||||
{
|
||||
name: 'Make it real',
|
||||
action: 'Make it real',
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-4-vision-preview',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -713,7 +731,7 @@ content: {{content}}`,
|
||||
{
|
||||
name: 'Make it real with text',
|
||||
action: 'Make it real with text',
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-4-vision-preview',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -43,6 +43,9 @@ export class OpenAIProvider
|
||||
// text to text
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-vision-preview',
|
||||
'gpt-4-turbo-preview',
|
||||
'gpt-3.5-turbo',
|
||||
// embeddings
|
||||
'text-embedding-3-large',
|
||||
'text-embedding-3-small',
|
||||
@@ -200,7 +203,7 @@ export class OpenAIProvider
|
||||
// ====== text to text ======
|
||||
async generateText(
|
||||
messages: PromptMessage[],
|
||||
model: string = 'gpt-4o-mini',
|
||||
model: string = 'gpt-3.5-turbo',
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
this.checkParams({ messages, model, options });
|
||||
@@ -229,11 +232,10 @@ export class OpenAIProvider
|
||||
|
||||
async *generateTextStream(
|
||||
messages: PromptMessage[],
|
||||
model: string = 'gpt-4o-mini',
|
||||
model: string = 'gpt-3.5-turbo',
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
this.checkParams({ messages, model, options });
|
||||
|
||||
try {
|
||||
const result = await this.instance.chat.completions.create(
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Float,
|
||||
ID,
|
||||
InputType,
|
||||
Mutation,
|
||||
@@ -206,16 +205,16 @@ class CopilotPromptConfigType {
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
jsonMode!: boolean | null;
|
||||
|
||||
@Field(() => Float, { nullable: true })
|
||||
@Field(() => Number, { nullable: true })
|
||||
frequencyPenalty!: number | null;
|
||||
|
||||
@Field(() => Float, { nullable: true })
|
||||
@Field(() => Number, { nullable: true })
|
||||
presencePenalty!: number | null;
|
||||
|
||||
@Field(() => Float, { nullable: true })
|
||||
@Field(() => Number, { nullable: true })
|
||||
temperature!: number | null;
|
||||
|
||||
@Field(() => Float, { nullable: true })
|
||||
@Field(() => Number, { nullable: true })
|
||||
topP!: number | null;
|
||||
}
|
||||
|
||||
@@ -239,8 +238,8 @@ class CopilotPromptType {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
model!: string;
|
||||
@Field(() => AvailableModels)
|
||||
model!: AvailableModels;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
action!: string | null;
|
||||
|
||||
@@ -283,13 +283,7 @@ export class ChatSessionService {
|
||||
docId: true,
|
||||
parentSessionId: true,
|
||||
messages: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
content: true,
|
||||
attachments: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: { id: true, role: true, content: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
promptName: true,
|
||||
@@ -577,7 +571,6 @@ export class ChatSessionService {
|
||||
|
||||
const forkedState = {
|
||||
...state,
|
||||
userId: options.userId,
|
||||
sessionId: randomUUID(),
|
||||
messages: [],
|
||||
parentSessionId: options.sessionId,
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { ChatPrompt } from './prompt';
|
||||
export enum AvailableModels {
|
||||
// text to text
|
||||
Gpt4Omni = 'gpt-4o',
|
||||
Gpt4OmniMini = 'gpt-4o-mini',
|
||||
Gpt4VisionPreview = 'gpt-4-vision-preview',
|
||||
Gpt4TurboPreview = 'gpt-4-turbo-preview',
|
||||
Gpt35Turbo = 'gpt-3.5-turbo',
|
||||
// embeddings
|
||||
TextEmbedding3Large = 'text-embedding-3-large',
|
||||
TextEmbedding3Small = 'text-embedding-3-small',
|
||||
@@ -32,8 +34,7 @@ export function getTokenEncoder(model?: string | null): Tokenizer | null {
|
||||
// dalle don't need to calc the token
|
||||
return null;
|
||||
} else {
|
||||
// c100k based model
|
||||
return fromModelName('gpt-4');
|
||||
return fromModelName('gpt-4-turbo-preview');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { ServerConfigType } from '../../core/config/types';
|
||||
import { ServerConfigType } from '../../core/config';
|
||||
import { OAuthProviderName } from './config';
|
||||
import { OAuthProviderFactory } from './register';
|
||||
|
||||
|
||||
@@ -84,17 +84,17 @@ export class SubscriptionService {
|
||||
private readonly db: PrismaClient,
|
||||
private readonly scheduleManager: ScheduleManager,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly feature: FeatureManagementService
|
||||
private readonly features: FeatureManagementService
|
||||
) {}
|
||||
|
||||
async listPrices(user?: CurrentUser) {
|
||||
let canHaveEarlyAccessDiscount = false;
|
||||
let canHaveAIEarlyAccessDiscount = false;
|
||||
if (user) {
|
||||
canHaveEarlyAccessDiscount = await this.feature.isEarlyAccessUser(
|
||||
canHaveEarlyAccessDiscount = await this.features.isEarlyAccessUser(
|
||||
user.id
|
||||
);
|
||||
canHaveAIEarlyAccessDiscount = await this.feature.isEarlyAccessUser(
|
||||
canHaveAIEarlyAccessDiscount = await this.features.isEarlyAccessUser(
|
||||
user.id,
|
||||
EarlyAccessType.AI
|
||||
);
|
||||
@@ -181,7 +181,7 @@ export class SubscriptionService {
|
||||
if (
|
||||
this.config.deploy &&
|
||||
this.config.affine.canary &&
|
||||
!this.feature.isStaff(user.email)
|
||||
!this.features.isStaff(user.email)
|
||||
) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
@@ -847,7 +847,7 @@ export class SubscriptionService {
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): Promise<{ price: string; coupon?: string }> {
|
||||
const isEaUser = await this.feature.isEarlyAccessUser(customer.userId);
|
||||
const isEaUser = await this.features.isEarlyAccessUser(customer.userId);
|
||||
const oldSubscriptions = await this.stripe.subscriptions.list({
|
||||
customer: customer.stripeCustomerId,
|
||||
status: 'all',
|
||||
@@ -876,7 +876,7 @@ export class SubscriptionService {
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
const isAIEaUser = await this.feature.isEarlyAccessUser(
|
||||
const isAIEaUser = await this.features.isEarlyAccessUser(
|
||||
customer.userId,
|
||||
EarlyAccessType.AI
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function createSockerIoAdapterImpl(
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
const server = super.createIOServer(port, options);
|
||||
const server = super.createIOServer(port, options) as Server;
|
||||
server.adapter(createAdapter(pubClient, subClient));
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ type CopilotMessageNotFoundDataType {
|
||||
enum CopilotModels {
|
||||
DallE3
|
||||
Gpt4Omni
|
||||
Gpt4OmniMini
|
||||
Gpt4TurboPreview
|
||||
Gpt4VisionPreview
|
||||
Gpt35Turbo
|
||||
TextEmbedding3Large
|
||||
TextEmbedding3Small
|
||||
TextEmbeddingAda002
|
||||
@@ -61,19 +63,19 @@ enum CopilotModels {
|
||||
}
|
||||
|
||||
input CopilotPromptConfigInput {
|
||||
frequencyPenalty: Float
|
||||
frequencyPenalty: Int
|
||||
jsonMode: Boolean
|
||||
presencePenalty: Float
|
||||
temperature: Float
|
||||
topP: Float
|
||||
presencePenalty: Int
|
||||
temperature: Int
|
||||
topP: Int
|
||||
}
|
||||
|
||||
type CopilotPromptConfigType {
|
||||
frequencyPenalty: Float
|
||||
frequencyPenalty: Int
|
||||
jsonMode: Boolean
|
||||
presencePenalty: Float
|
||||
temperature: Float
|
||||
topP: Float
|
||||
presencePenalty: Int
|
||||
temperature: Int
|
||||
topP: Int
|
||||
}
|
||||
|
||||
input CopilotPromptMessageInput {
|
||||
@@ -102,7 +104,7 @@ type CopilotPromptType {
|
||||
action: String
|
||||
config: CopilotPromptConfigType
|
||||
messages: [CopilotPromptMessageType!]!
|
||||
model: String!
|
||||
model: CopilotModels!
|
||||
name: String!
|
||||
}
|
||||
|
||||
@@ -152,6 +154,7 @@ input CreateCopilotPromptInput {
|
||||
input CreateUserInput {
|
||||
email: String!
|
||||
name: String
|
||||
password: String
|
||||
}
|
||||
|
||||
type CredentialsRequirementType {
|
||||
@@ -195,6 +198,11 @@ type DocNotFoundDataType {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
enum EarlyAccessType {
|
||||
AI
|
||||
App
|
||||
}
|
||||
|
||||
union ErrorDataUnion = BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType
|
||||
|
||||
enum ErrorNames {
|
||||
@@ -203,8 +211,6 @@ enum ErrorNames {
|
||||
AUTHENTICATION_REQUIRED
|
||||
BLOB_NOT_FOUND
|
||||
BLOB_QUOTA_EXCEEDED
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
|
||||
CANNOT_DELETE_OWN_ACCOUNT
|
||||
CANT_CHANGE_WORKSPACE_OWNER
|
||||
CANT_UPDATE_LIFETIME_SUBSCRIPTION
|
||||
COPILOT_ACTION_TAKEN
|
||||
@@ -392,20 +398,14 @@ input ListUserInput {
|
||||
skip: Int = 0
|
||||
}
|
||||
|
||||
input ManageUserInput {
|
||||
"""User email"""
|
||||
email: String
|
||||
|
||||
"""User name"""
|
||||
name: String
|
||||
}
|
||||
|
||||
type MissingOauthQueryParameterDataType {
|
||||
name: String!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
||||
addAdminister(email: String!): Boolean!
|
||||
addToEarlyAccess(email: String!, type: EarlyAccessType!): Int!
|
||||
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
|
||||
changeEmail(email: String!, token: String!): UserType!
|
||||
@@ -414,9 +414,6 @@ type Mutation {
|
||||
"""Cleanup sessions"""
|
||||
cleanupCopilotSession(options: DeleteSessionInput!): [String!]!
|
||||
|
||||
"""Create change password url"""
|
||||
createChangePasswordUrl(callbackUrl: String!, userId: String!): String!
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||
|
||||
@@ -453,6 +450,7 @@ type Mutation {
|
||||
|
||||
"""Remove user avatar"""
|
||||
removeAvatar: RemoveAvatar!
|
||||
removeEarlyAccess(email: String!): Int!
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
|
||||
revoke(userId: String!, workspaceId: String!): Boolean!
|
||||
@@ -478,12 +476,6 @@ type Mutation {
|
||||
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
|
||||
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
|
||||
|
||||
"""Update a user"""
|
||||
updateUser(id: String!, input: ManageUserInput!): UserType!
|
||||
|
||||
"""update user enabled feature"""
|
||||
updateUserFeatures(features: [FeatureType!]!, id: String!): [FeatureType!]!
|
||||
|
||||
"""Update workspace"""
|
||||
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
|
||||
|
||||
@@ -527,6 +519,7 @@ type Query {
|
||||
|
||||
"""Get current user"""
|
||||
currentUser: UserType
|
||||
earlyAccessUsers: [UserType!]!
|
||||
error(name: ErrorNames!): ErrorDataUnion!
|
||||
|
||||
"""send workspace invitation"""
|
||||
@@ -553,18 +546,12 @@ type Query {
|
||||
"""Get user by email"""
|
||||
user(email: String!): UserOrLimitedUser
|
||||
|
||||
"""Get user by email for admin"""
|
||||
userByEmail(email: String!): UserType
|
||||
|
||||
"""Get user by id"""
|
||||
userById(id: String!): UserType!
|
||||
|
||||
"""List registered users"""
|
||||
users(filter: ListUserInput!): [UserType!]!
|
||||
|
||||
"""Get users count"""
|
||||
usersCount: Int!
|
||||
|
||||
"""Get workspace by id"""
|
||||
workspace(id: String!): WorkspaceType!
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test('should be able to visit public api if signed in', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
auth.getUser.resolves({ user: { id: '1' } });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/public')
|
||||
@@ -100,7 +100,7 @@ test('should be able to visit private api if signed in', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
auth.getUser.resolves({ user: { id: '1' } });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/private')
|
||||
@@ -114,26 +114,26 @@ test('should be able to parse session cookie', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
auth.getUser.resolves({ user: { id: '1' } });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/public')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=1`)
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
|
||||
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
|
||||
});
|
||||
|
||||
test('should be able to parse bearer token', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
auth.getUser.resolves({ user: { id: '1' } });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/public')
|
||||
.auth('1', { type: 'bearer' })
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
|
||||
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
|
||||
});
|
||||
|
||||
@@ -157,10 +157,10 @@ test('should be able to get user from session', async t => {
|
||||
|
||||
const session = await auth.createUserSession(u1);
|
||||
|
||||
const userSession = await auth.getUserSession(session.sessionId);
|
||||
const { user } = await auth.getUser(session.sessionId);
|
||||
|
||||
t.not(userSession, null);
|
||||
t.is(userSession!.user.id, u1.id);
|
||||
t.not(user, null);
|
||||
t.is(user!.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to sign out session', async t => {
|
||||
@@ -203,19 +203,19 @@ test('should be able to signout multi accounts session', async t => {
|
||||
|
||||
t.not(signedOutSession, null);
|
||||
|
||||
const userSession1 = await auth.getUserSession(session.sessionId, 0);
|
||||
const userSession2 = await auth.getUserSession(session.sessionId, 1);
|
||||
const { user: signedU2 } = await auth.getUser(session.sessionId, 0);
|
||||
const { user: noUser } = await auth.getUser(session.sessionId, 1);
|
||||
|
||||
t.is(userSession2, null);
|
||||
t.not(userSession1, null);
|
||||
t.is(noUser, null);
|
||||
t.not(signedU2, null);
|
||||
|
||||
t.is(userSession1!.user.id, u2.id);
|
||||
t.is(signedU2!.id, u2.id);
|
||||
|
||||
// sign out user at seq(0)
|
||||
signedOutSession = await auth.signOut(session.sessionId);
|
||||
|
||||
t.is(signedOutSession, null);
|
||||
|
||||
const userSession3 = await auth.getUserSession(session.sessionId, 0);
|
||||
t.is(userSession3, null);
|
||||
const { user: noUser2 } = await auth.getUser(session.sessionId, 0);
|
||||
t.is(noUser2, null);
|
||||
});
|
||||
|
||||
@@ -246,16 +246,14 @@ test('should be able to manage chat session', async t => {
|
||||
|
||||
const s1 = (await session.get(sessionId))!;
|
||||
t.deepEqual(
|
||||
s1
|
||||
.finish(params)
|
||||
// @ts-expect-error
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m),
|
||||
// @ts-expect-error
|
||||
s1.finish(params).map(({ id: _, createdAt: __, ...m }) => m),
|
||||
finalMessages,
|
||||
'should same as before message'
|
||||
);
|
||||
t.deepEqual(
|
||||
// @ts-expect-error
|
||||
s1.finish({}).map(({ id: _, attachments: __, createdAt: ___, ...m }) => m),
|
||||
s1.finish({}).map(({ id: _, createdAt: __, ...m }) => m),
|
||||
[
|
||||
{ content: 'hello ', params: {}, role: 'system' },
|
||||
{ content: 'hello', role: 'user' },
|
||||
@@ -275,7 +273,7 @@ test('should be able to manage chat session', async t => {
|
||||
});
|
||||
|
||||
test('should be able to fork chat session', async t => {
|
||||
const { auth, prompt, session } = t.context;
|
||||
const { prompt, session } = t.context;
|
||||
|
||||
await prompt.set('prompt', 'model', [
|
||||
{ role: 'system', content: 'hello {{word}}' },
|
||||
@@ -307,10 +305,8 @@ test('should be able to fork chat session', async t => {
|
||||
...commonParams,
|
||||
});
|
||||
t.not(sessionId, forkedSessionId1, 'should fork a new session');
|
||||
|
||||
const newUser = await auth.signUp('test', 'darksky.1@affine.pro', '123456');
|
||||
const forkedSessionId2 = await session.fork({
|
||||
userId: newUser.id,
|
||||
userId,
|
||||
sessionId,
|
||||
latestMessageId,
|
||||
...commonParams,
|
||||
@@ -327,7 +323,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
const finalMessages = s2
|
||||
.finish(params) // @ts-expect-error
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
|
||||
.map(({ id: _, createdAt: __, ...m }) => m);
|
||||
t.deepEqual(
|
||||
finalMessages,
|
||||
[
|
||||
@@ -339,16 +335,13 @@ test('should be able to fork chat session', async t => {
|
||||
);
|
||||
}
|
||||
|
||||
// check second times forked session
|
||||
// check second times forked session messages
|
||||
{
|
||||
const s2 = (await session.get(forkedSessionId2))!;
|
||||
|
||||
// should overwrite user id
|
||||
t.is(s2.config.userId, newUser.id, 'should have same user id');
|
||||
|
||||
const finalMessages = s2
|
||||
.finish(params) // @ts-expect-error
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
|
||||
.map(({ id: _, createdAt: __, ...m }) => m);
|
||||
t.deepEqual(
|
||||
finalMessages,
|
||||
[
|
||||
@@ -366,7 +359,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
const finalMessages = s3
|
||||
.finish(params) // @ts-expect-error
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
|
||||
.map(({ id: _, createdAt: __, ...m }) => m);
|
||||
t.deepEqual(
|
||||
finalMessages,
|
||||
[
|
||||
|
||||
@@ -341,10 +341,8 @@ test('should throw if oauth account already connected', async t => {
|
||||
},
|
||||
});
|
||||
|
||||
Sinon.stub(auth, 'getUserSession').resolves({
|
||||
user: { id: 'u2-id' },
|
||||
session: {},
|
||||
} as any);
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(auth, 'getUser').resolves({ user: { id: 'u2-id' } });
|
||||
|
||||
mockOAuthProvider(app, 'u2@affine.pro');
|
||||
|
||||
@@ -365,10 +363,8 @@ test('should throw if oauth account already connected', async t => {
|
||||
test('should be able to connect oauth account', async t => {
|
||||
const { app, u1, auth, db } = t.context;
|
||||
|
||||
Sinon.stub(auth, 'getUserSession').resolves({
|
||||
user: { id: u1.id },
|
||||
session: {},
|
||||
} as any);
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(auth, 'getUser').resolves({ user: { id: u1.id } });
|
||||
|
||||
mockOAuthProvider(app, u1.email);
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export class MockCopilotTestProvider
|
||||
|
||||
override async *generateTextStream(
|
||||
messages: PromptMessage[],
|
||||
model: string = 'gpt-4o-mini',
|
||||
model: string = 'gpt-3.5-turbo',
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
this.checkParams({ messages, model, options });
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@types/debug": "^4.1.12",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"version": "0.16.0"
|
||||
"version": "0.15.0"
|
||||
}
|
||||
|
||||
Vendored
+3
-3
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/store": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/global": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@blocksuite/store": "0.16.0-canary-202407301803-87f6a75",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"vitest": "1.6.0"
|
||||
@@ -26,5 +26,5 @@
|
||||
"lit": "^3.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"version": "0.16.0"
|
||||
"version": "0.15.0"
|
||||
}
|
||||
|
||||
Vendored
+7
-1
@@ -31,7 +31,13 @@ export const runtimeFlagsSchema = z.object({
|
||||
enableExperimentalFeature: z.boolean(),
|
||||
enableInfoModal: z.boolean(),
|
||||
enableOrganize: z.boolean(),
|
||||
enableThemeEditor: z.boolean(),
|
||||
// show the new favorite, which exclusive to each user
|
||||
enableNewFavorite: z.boolean(),
|
||||
// show the old favorite
|
||||
enableOldFavorite: z.boolean(),
|
||||
// before 0.16, enableNewFavorite = false and enableOldFavorite = true
|
||||
// after 0.16, enableNewFavorite = true and enableOldFavorite = false
|
||||
// for debug purpose, we can enable both
|
||||
});
|
||||
|
||||
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
|
||||
|
||||
Vendored
+4
-8
@@ -1,3 +1,5 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
export class UaHelper {
|
||||
private readonly uaMap;
|
||||
public isLinux = false;
|
||||
@@ -10,14 +12,8 @@ export class UaHelper {
|
||||
public isIOS = false;
|
||||
|
||||
getChromeVersion = (): number => {
|
||||
let raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
if (!raw) {
|
||||
raw = this.navigator.userAgent.match(/(CriOS)\/([0-9]+)/);
|
||||
}
|
||||
if (!raw) {
|
||||
console.error('Cannot get chrome version');
|
||||
return 0;
|
||||
}
|
||||
const raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
assertExists(raw);
|
||||
return parseInt(raw[2], 10);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/blocks": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/global": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/presets": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/store": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/blocks": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@blocksuite/global": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@blocksuite/presets": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@blocksuite/store": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@datastructures-js/binary-search-tree": "^5.3.2",
|
||||
"foxact": "^0.2.33",
|
||||
"fuse.js": "^7.0.0",
|
||||
@@ -34,15 +34,15 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/block-std": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/presets": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/block-std": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@blocksuite/presets": "0.16.0-canary-202407301803-87f6a75",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"async-call-rpc": "^6.4.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-dts": "4.0.2",
|
||||
"vite-plugin-dts": "3.9.1",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -73,5 +73,5 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"version": "0.16.0"
|
||||
"version": "0.15.0"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type AppSetting = {
|
||||
autoDownloadUpdate: boolean;
|
||||
enableMultiView: boolean;
|
||||
enableTelemetry: boolean;
|
||||
enableOutlineViewer: boolean;
|
||||
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
|
||||
};
|
||||
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
|
||||
@@ -74,6 +75,7 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
|
||||
autoDownloadUpdate: true,
|
||||
enableTelemetry: true,
|
||||
enableMultiView: false,
|
||||
enableOutlineViewer: false,
|
||||
editorFlags: {},
|
||||
});
|
||||
|
||||
@@ -91,14 +93,8 @@ export function setupEditorFlags(docCollection: DocCollection) {
|
||||
|
||||
// override this flag in app settings
|
||||
// TODO(@eyhn): need a better way to manage block suite flags
|
||||
Object.entries(blocksuiteFeatureFlags).forEach(([key, value]) => {
|
||||
if (value.defaultState !== undefined) {
|
||||
docCollection.awarenessStore.setFlag(
|
||||
key as keyof BlockSuiteFlags,
|
||||
value.defaultState
|
||||
);
|
||||
}
|
||||
});
|
||||
docCollection.awarenessStore.setFlag('enable_synced_doc_block', true);
|
||||
docCollection.awarenessStore.setFlag('enable_edgeless_text', true);
|
||||
} catch (err) {
|
||||
logger.error('syncEditorFlags', err);
|
||||
}
|
||||
@@ -143,89 +139,3 @@ export const appSettingAtom = atom<
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export type BuildChannel = 'stable' | 'beta' | 'canary' | 'internal';
|
||||
|
||||
export type FeedbackType = 'discord' | 'email' | 'github';
|
||||
|
||||
export type PreconditionType = () => boolean | undefined;
|
||||
|
||||
export type Flag<K extends string> = Partial<{
|
||||
[key in K]: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
precondition?: PreconditionType;
|
||||
defaultState?: boolean; // default to open and not controlled by user
|
||||
feedbackType?: FeedbackType;
|
||||
};
|
||||
}>;
|
||||
|
||||
const isNotStableBuild: PreconditionType = () => {
|
||||
return runtimeConfig.appBuildType !== 'stable';
|
||||
};
|
||||
const isDesktopEnvironment: PreconditionType = () => environment.isDesktop;
|
||||
const neverShow: PreconditionType = () => false;
|
||||
|
||||
export const blocksuiteFeatureFlags: Flag<keyof BlockSuiteFlags> = {
|
||||
enable_database_attachment_note: {
|
||||
displayName: 'Database Attachment Note',
|
||||
description: 'Allows adding notes to database attachments.',
|
||||
precondition: isNotStableBuild,
|
||||
},
|
||||
enable_database_statistics: {
|
||||
displayName: 'Database Block Statistics',
|
||||
description: 'Shows statistics for database blocks.',
|
||||
precondition: isNotStableBuild,
|
||||
},
|
||||
enable_block_query: {
|
||||
displayName: 'Todo Block Query',
|
||||
description: 'Enables querying of todo blocks.',
|
||||
precondition: isNotStableBuild,
|
||||
},
|
||||
enable_synced_doc_block: {
|
||||
displayName: 'Synced Doc Block',
|
||||
description: 'Enables syncing of doc blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_edgeless_text: {
|
||||
displayName: 'Edgeless Text',
|
||||
description: 'Enables edgeless text blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_color_picker: {
|
||||
displayName: 'Color Picker',
|
||||
description: 'Enables color picker blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_ai_chat_block: {
|
||||
displayName: 'AI Chat Block',
|
||||
description: 'Enables AI chat blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_ai_onboarding: {
|
||||
displayName: 'AI Onboarding',
|
||||
description: 'Enables AI onboarding.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_expand_database_block: {
|
||||
displayName: 'Expand Database Block',
|
||||
description: 'Enables expanding of database blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const affineFeatureFlags: Flag<keyof AppSetting> = {
|
||||
enableMultiView: {
|
||||
displayName: 'Split View',
|
||||
description:
|
||||
'The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.',
|
||||
feedbackType: 'discord',
|
||||
precondition: isDesktopEnvironment,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -495,8 +495,7 @@ export class LiveData<T = unknown>
|
||||
throw this.poisonedError;
|
||||
}
|
||||
this.ops$.next('watch');
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- never throw
|
||||
Promise.resolve().then(() => {
|
||||
setImmediate(() => {
|
||||
this.ops$.next('unwatch');
|
||||
});
|
||||
return this.raw$.value;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Entity } from '../../../framework';
|
||||
import type { DBSchemaBuilder, TableMap } from '../../../orm';
|
||||
import { Table } from './table';
|
||||
|
||||
export class DB<Schema extends DBSchemaBuilder> extends Entity<{
|
||||
db: TableMap<Schema>;
|
||||
schema: Schema;
|
||||
storageDocId: (tableName: string) => string;
|
||||
}> {
|
||||
readonly db = this.props.db;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
Object.entries(this.props.schema).forEach(([tableName]) => {
|
||||
const table = this.framework.createEntity(Table, {
|
||||
table: this.db[tableName],
|
||||
storageDocId: this.props.storageDocId(tableName),
|
||||
});
|
||||
Object.defineProperty(this, tableName, {
|
||||
get: () => table,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type DBWithTables<Schema extends DBSchemaBuilder> = DB<Schema> & {
|
||||
[K in keyof Schema]: Table<Schema[K]>;
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Entity } from '../../../framework';
|
||||
import type { Table as OrmTable, TableSchemaBuilder } from '../../../orm/core';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export class Table<Schema extends TableSchemaBuilder> extends Entity<{
|
||||
table: OrmTable<Schema>;
|
||||
storageDocId: string;
|
||||
}> {
|
||||
readonly table = this.props.table;
|
||||
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSyncing$ = this.workspaceService.workspace.engine.doc
|
||||
.docState$(this.props.storageDocId)
|
||||
.map(docState => docState.syncing);
|
||||
|
||||
isLoading$ = this.workspaceService.workspace.engine.doc
|
||||
.docState$(this.props.storageDocId)
|
||||
.map(docState => docState.loading);
|
||||
|
||||
create = this.table.create.bind(this.table);
|
||||
update = this.table.update.bind(this.table);
|
||||
get = this.table.get.bind(this.table);
|
||||
// eslint-disable-next-line rxjs/finnish
|
||||
get$ = this.table.get$.bind(this.table);
|
||||
find = this.table.find.bind(this.table);
|
||||
// eslint-disable-next-line rxjs/finnish
|
||||
find$ = this.table.find$.bind(this.table);
|
||||
keys = this.table.keys.bind(this.table);
|
||||
delete = this.table.delete.bind(this.table);
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { Framework } from '../../framework';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { DB } from './entities/db';
|
||||
import { Table } from './entities/table';
|
||||
import { WorkspaceDBService } from './services/db';
|
||||
|
||||
export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema';
|
||||
export { WorkspaceDBService } from './services/db';
|
||||
export { transformWorkspaceDBLocalToCloud } from './services/db';
|
||||
|
||||
export function configureWorkspaceDBModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceDBService, [WorkspaceService])
|
||||
.entity(DB)
|
||||
.entity(Table, [WorkspaceService]);
|
||||
.service(WorkspaceDBService, [WorkspaceService]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Doc as YDoc } from 'yjs';
|
||||
|
||||
import { Service } from '../../../framework';
|
||||
import { createORMClient, YjsDBAdapter } from '../../../orm';
|
||||
import type { DocStorage } from '../../../sync';
|
||||
import { createORMClient, type TableMap, YjsDBAdapter } from '../../../orm';
|
||||
import { ObjectPool } from '../../../utils';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import { DB, type DBWithTables } from '../entities/db';
|
||||
import { AFFiNE_WORKSPACE_DB_SCHEMA } from '../schema';
|
||||
import { AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA } from '../schema/schema';
|
||||
|
||||
@@ -13,13 +11,11 @@ const WorkspaceDBClient = createORMClient(AFFiNE_WORKSPACE_DB_SCHEMA);
|
||||
const WorkspaceUserdataDBClient = createORMClient(
|
||||
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA
|
||||
);
|
||||
type WorkspaceUserdataDBClient = InstanceType<typeof WorkspaceUserdataDBClient>;
|
||||
|
||||
export class WorkspaceDBService extends Service {
|
||||
db: DBWithTables<AFFiNE_WORKSPACE_DB_SCHEMA>;
|
||||
userdataDBPool = new ObjectPool<
|
||||
string,
|
||||
DB<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>
|
||||
>({
|
||||
db: TableMap<AFFiNE_WORKSPACE_DB_SCHEMA>;
|
||||
userdataDBPool = new ObjectPool<string, WorkspaceUserdataDBClient>({
|
||||
onDangling() {
|
||||
return false; // never release
|
||||
},
|
||||
@@ -27,27 +23,19 @@ export class WorkspaceDBService extends Service {
|
||||
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
this.db = this.framework.createEntity(DB<AFFiNE_WORKSPACE_DB_SCHEMA>, {
|
||||
db: new WorkspaceDBClient(
|
||||
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
|
||||
getDoc: guid => {
|
||||
const ydoc = new YDoc({
|
||||
// guid format: db${workspaceId}${guid}
|
||||
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
|
||||
});
|
||||
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
|
||||
this.workspaceService.workspace.engine.doc.setPriority(
|
||||
ydoc.guid,
|
||||
50
|
||||
);
|
||||
return ydoc;
|
||||
},
|
||||
})
|
||||
),
|
||||
schema: AFFiNE_WORKSPACE_DB_SCHEMA,
|
||||
storageDocId: tableName =>
|
||||
`db$${this.workspaceService.workspace.id}$${tableName}`,
|
||||
}) as DBWithTables<AFFiNE_WORKSPACE_DB_SCHEMA>;
|
||||
this.db = new WorkspaceDBClient(
|
||||
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
|
||||
getDoc: guid => {
|
||||
const ydoc = new YDoc({
|
||||
// guid format: db${workspaceId}${guid}
|
||||
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
|
||||
});
|
||||
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
|
||||
this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50);
|
||||
return ydoc;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
@@ -55,65 +43,28 @@ export class WorkspaceDBService extends Service {
|
||||
// __local__ for local workspace
|
||||
const userdataDb = this.userdataDBPool.get(userId);
|
||||
if (userdataDb) {
|
||||
return userdataDb.obj as DBWithTables<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>;
|
||||
return userdataDb.obj;
|
||||
}
|
||||
|
||||
const newDB = this.framework.createEntity(
|
||||
DB<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>,
|
||||
{
|
||||
db: new WorkspaceUserdataDBClient(
|
||||
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
|
||||
getDoc: guid => {
|
||||
const ydoc = new YDoc({
|
||||
// guid format: userdata${userId}${workspaceId}${guid}
|
||||
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
|
||||
});
|
||||
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
|
||||
this.workspaceService.workspace.engine.doc.setPriority(
|
||||
ydoc.guid,
|
||||
50
|
||||
);
|
||||
return ydoc;
|
||||
},
|
||||
})
|
||||
),
|
||||
schema: AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
|
||||
storageDocId: tableName =>
|
||||
`userdata$${userId}$${this.workspaceService.workspace.id}$${tableName}`,
|
||||
}
|
||||
const newDB = new WorkspaceUserdataDBClient(
|
||||
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
|
||||
getDoc: guid => {
|
||||
const ydoc = new YDoc({
|
||||
// guid format: userdata${userId}${workspaceId}${guid}
|
||||
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
|
||||
});
|
||||
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
|
||||
this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50);
|
||||
return ydoc;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
this.userdataDBPool.put(userId, newDB);
|
||||
return newDB as DBWithTables<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>;
|
||||
return newDB;
|
||||
}
|
||||
|
||||
static isDBDocId(docId: string) {
|
||||
return docId.startsWith('db$') || docId.startsWith('userdata$');
|
||||
}
|
||||
}
|
||||
|
||||
export async function transformWorkspaceDBLocalToCloud(
|
||||
localWorkspaceId: string,
|
||||
cloudWorkspaceId: string,
|
||||
localDocStorage: DocStorage,
|
||||
cloudDocStorage: DocStorage,
|
||||
accountId: string
|
||||
) {
|
||||
for (const tableName of Object.keys(AFFiNE_WORKSPACE_DB_SCHEMA)) {
|
||||
const localDocName = `db$${localWorkspaceId}$${tableName}`;
|
||||
const localDoc = await localDocStorage.doc.get(localDocName);
|
||||
if (localDoc) {
|
||||
const cloudDocName = `db$${cloudWorkspaceId}$${tableName}`;
|
||||
await cloudDocStorage.doc.set(cloudDocName, localDoc);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tableName of Object.keys(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA)) {
|
||||
const localDocName = `userdata$__local__$${localWorkspaceId}$${tableName}`;
|
||||
const localDoc = await localDocStorage.doc.get(localDocName);
|
||||
if (localDoc) {
|
||||
const cloudDocName = `userdata$${accountId}$${cloudWorkspaceId}$${tableName}`;
|
||||
await cloudDocStorage.doc.set(cloudDocName, localDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,36 +8,19 @@ export class GlobalContext extends Entity {
|
||||
|
||||
workspaceId = this.define<string>('workspaceId');
|
||||
|
||||
/**
|
||||
* is in doc page
|
||||
*/
|
||||
isDoc = this.define<boolean>('isDoc');
|
||||
isTrashDoc = this.define<boolean>('isTrashDoc');
|
||||
docId = this.define<string>('docId');
|
||||
docMode = this.define<DocMode>('docMode');
|
||||
|
||||
/**
|
||||
* is in collection page
|
||||
*/
|
||||
isCollection = this.define<boolean>('isCollection');
|
||||
collectionId = this.define<string>('collectionId');
|
||||
|
||||
/**
|
||||
* is in trash page
|
||||
*/
|
||||
isTrash = this.define<boolean>('isTrash');
|
||||
|
||||
/**
|
||||
* is in tag page
|
||||
*/
|
||||
docMode = this.define<DocMode>('docMode');
|
||||
|
||||
isTag = this.define<boolean>('isTag');
|
||||
tagId = this.define<string>('tagId');
|
||||
|
||||
/**
|
||||
* is in all docs page
|
||||
*/
|
||||
isAllDocs = this.define<boolean>('isAllDocs');
|
||||
|
||||
define<T>(key: string) {
|
||||
this.memento.set(key, null);
|
||||
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);
|
||||
|
||||
@@ -34,8 +34,6 @@ export class Workspace extends Entity {
|
||||
},
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
}
|
||||
return this._docCollection;
|
||||
|
||||
@@ -28,8 +28,7 @@ export interface WorkspaceFlavourProvider {
|
||||
createWorkspace(
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage,
|
||||
docStorage: DocStorage
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata>;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
|
||||
import { Service } from '../../../framework';
|
||||
import type { BlobStorage, DocStorage } from '../../../sync';
|
||||
import type { BlobStorage } from '../../../sync';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
|
||||
export class WorkspaceFactoryService extends Service {
|
||||
@@ -20,8 +20,7 @@ export class WorkspaceFactoryService extends Service {
|
||||
flavour: WorkspaceFlavour,
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage,
|
||||
docStorage: DocStorage
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void> = () => Promise.resolve()
|
||||
) => {
|
||||
const provider = this.providers.find(x => x.flavour === flavour);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { assertEquals } from '@blocksuite/global/utils';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { Service } from '../../../framework';
|
||||
import { transformWorkspaceDBLocalToCloud } from '../../db';
|
||||
import type { Workspace } from '../entities/workspace';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import type { WorkspaceDestroyService } from './destroy';
|
||||
@@ -19,12 +18,9 @@ export class WorkspaceTransformService extends Service {
|
||||
|
||||
/**
|
||||
* helper function to transform local workspace to cloud workspace
|
||||
*
|
||||
* @param accountId - all local user data will be transformed to this account
|
||||
*/
|
||||
transformLocalToCloud = async (
|
||||
local: Workspace,
|
||||
accountId: string
|
||||
local: Workspace
|
||||
): Promise<WorkspaceMetadata> => {
|
||||
assertEquals(local.flavour, WorkspaceFlavour.LOCAL);
|
||||
|
||||
@@ -32,35 +28,23 @@ export class WorkspaceTransformService extends Service {
|
||||
|
||||
const newMetadata = await this.factory.create(
|
||||
WorkspaceFlavour.AFFINE_CLOUD,
|
||||
async (docCollection, blobStorage, docStorage) => {
|
||||
applyUpdate(
|
||||
docCollection.doc,
|
||||
encodeStateAsUpdate(local.docCollection.doc)
|
||||
);
|
||||
async (ws, bs) => {
|
||||
applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc));
|
||||
|
||||
for (const subdoc of local.docCollection.doc.getSubdocs()) {
|
||||
for (const newSubdoc of docCollection.doc.getSubdocs()) {
|
||||
for (const newSubdoc of ws.doc.getSubdocs()) {
|
||||
if (newSubdoc.guid === subdoc.guid) {
|
||||
applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// transform db
|
||||
await transformWorkspaceDBLocalToCloud(
|
||||
local.id,
|
||||
docCollection.id,
|
||||
local.engine.doc.storage.behavior,
|
||||
docStorage,
|
||||
accountId
|
||||
);
|
||||
|
||||
const blobList = await local.engine.blob.list();
|
||||
|
||||
for (const blobKey of blobList) {
|
||||
const blob = await local.engine.blob.get(blobKey);
|
||||
if (blob) {
|
||||
await blobStorage.set(blobKey, blob);
|
||||
await bs.set(blobKey, blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,7 @@ import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
import { Service } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import { wrapMemento } from '../../../storage';
|
||||
import {
|
||||
type BlobStorage,
|
||||
type DocStorage,
|
||||
MemoryDocStorage,
|
||||
} from '../../../sync';
|
||||
import { type BlobStorage, MemoryDocStorage } from '../../../sync';
|
||||
import { MemoryBlobStorage } from '../../../sync/blob/blob';
|
||||
import type { GlobalState } from '../../storage';
|
||||
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||
@@ -43,8 +39,7 @@ export class TestingWorkspaceLocalProvider
|
||||
async createWorkspace(
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage,
|
||||
docStorage: DocStorage
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata> {
|
||||
const id = nanoid();
|
||||
@@ -61,12 +56,10 @@ export class TestingWorkspaceLocalProvider
|
||||
blobSources: {
|
||||
main: blobStorage,
|
||||
},
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
await initial(docCollection, blobStorage, this.docStorage);
|
||||
await initial(docCollection, blobStorage);
|
||||
|
||||
// save workspace to storage
|
||||
await this.docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
|
||||
@@ -97,8 +90,6 @@ export class TestingWorkspaceLocalProvider
|
||||
const bs = new DocCollection({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
applyUpdate(bs.doc, data);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { validators } from './validators';
|
||||
|
||||
export class ORMClient {
|
||||
static hooksMap: Map<string, Hook<any>[]> = new Map();
|
||||
readonly tables = new Map<string, Table<any>>();
|
||||
private readonly tables = new Map<string, Table<any>>();
|
||||
constructor(
|
||||
protected readonly db: DBSchemaBuilder,
|
||||
protected readonly adapter: DBAdapter
|
||||
|
||||
@@ -22,25 +22,6 @@ export {
|
||||
ReadonlyStorage as ReadonlyDocStorage,
|
||||
} from './storage';
|
||||
|
||||
export interface DocEngineDocState {
|
||||
/**
|
||||
* is syncing with the server
|
||||
*/
|
||||
syncing: boolean;
|
||||
/**
|
||||
* is saving to local storage
|
||||
*/
|
||||
saving: boolean;
|
||||
/**
|
||||
* is loading from local storage
|
||||
*/
|
||||
loading: boolean;
|
||||
retrying: boolean;
|
||||
ready: boolean;
|
||||
errorMessage: string | null;
|
||||
serverClock: number | null;
|
||||
}
|
||||
|
||||
export class DocEngine {
|
||||
readonly clientId: string;
|
||||
localPart: DocEngineLocalPart;
|
||||
@@ -72,14 +53,13 @@ export class DocEngine {
|
||||
docState$(docId: string) {
|
||||
const localState$ = this.localPart.docState$(docId);
|
||||
const remoteState$ = this.remotePart?.docState$(docId);
|
||||
return LiveData.computed<DocEngineDocState>(get => {
|
||||
return LiveData.computed(get => {
|
||||
const localState = get(localState$);
|
||||
const remoteState = remoteState$ ? get(remoteState$) : null;
|
||||
if (remoteState) {
|
||||
return {
|
||||
syncing: remoteState.syncing,
|
||||
saving: localState.syncing,
|
||||
loading: localState.syncing,
|
||||
retrying: remoteState.retrying,
|
||||
ready: localState.ready,
|
||||
errorMessage: remoteState.errorMessage,
|
||||
@@ -89,7 +69,6 @@ export class DocEngine {
|
||||
return {
|
||||
syncing: localState.syncing,
|
||||
saving: localState.syncing,
|
||||
loading: localState.syncing,
|
||||
ready: localState.ready,
|
||||
retrying: false,
|
||||
errorMessage: null,
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface LocalEngineState {
|
||||
|
||||
export interface LocalDocState {
|
||||
ready: boolean;
|
||||
loading: boolean;
|
||||
syncing: boolean;
|
||||
}
|
||||
|
||||
@@ -82,7 +81,6 @@ export class DocEngineLocalPart {
|
||||
const next = () => {
|
||||
subscribe.next({
|
||||
ready: this.status.readyDocs.has(docId) ?? false,
|
||||
loading: this.status.connectedDocs.has(docId),
|
||||
syncing:
|
||||
(this.status.jobMap.get(docId)?.length ?? 0) > 0 ||
|
||||
this.status.currentJob?.docId === docId,
|
||||
@@ -93,7 +91,7 @@ export class DocEngineLocalPart {
|
||||
if (updatedId === docId) next();
|
||||
});
|
||||
}),
|
||||
{ ready: false, loading: false, syncing: false }
|
||||
{ ready: false, syncing: false }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,4 @@ test('bm25', () => {
|
||||
expect(bm25(1, 1, 10, 10, 15)).toEqual(3.2792079793859643);
|
||||
expect(bm25(2, 1, 10, 10, 15) > bm25(1, 1, 10, 10, 15)).toBeTruthy();
|
||||
expect(bm25(1, 1, 10, 10, 15) > bm25(2, 1, 10, 100, 15)).toBeTruthy();
|
||||
expect(bm25(1, 1, 10, 10, 15) > bm25(1, 1, 10, 100, 15)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -57,6 +57,6 @@ export const bm25 = (
|
||||
invDocFreq *
|
||||
(d +
|
||||
(termFreq * (k + 1)) /
|
||||
(termFreq + k * (1 - b + b * (fieldLength / avgFieldLength))))
|
||||
(termFreq + k * (1 - b + (b * fieldLength) / avgFieldLength)))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -178,13 +178,10 @@ export class FullTextInvertedIndex implements InvertedIndex {
|
||||
const queryTokens = new GeneralTokenizer().tokenize(term);
|
||||
const matched = new Map<
|
||||
number,
|
||||
Map<
|
||||
number, // index
|
||||
{
|
||||
score: number;
|
||||
ranges: [number, number][];
|
||||
}
|
||||
>
|
||||
{
|
||||
score: number[];
|
||||
positions: Map<number, [number, number][]>;
|
||||
}
|
||||
>();
|
||||
for (const token of queryTokens) {
|
||||
const key = InvertedIndexKey.forString(this.fieldKey, token.term);
|
||||
@@ -247,48 +244,30 @@ export class FullTextInvertedIndex implements InvertedIndex {
|
||||
|
||||
// normalize score
|
||||
const maxScore = submatched.reduce((acc, s) => Math.max(acc, s.score), 0);
|
||||
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 0);
|
||||
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 1);
|
||||
for (const { nid, score, position } of submatched) {
|
||||
const normalizedScore =
|
||||
maxScore === minScore
|
||||
? score
|
||||
: (score - minScore) / (maxScore - minScore);
|
||||
const match =
|
||||
matched.get(nid) ??
|
||||
new Map<
|
||||
number, // index
|
||||
{
|
||||
score: number;
|
||||
ranges: [number, number][];
|
||||
}
|
||||
>();
|
||||
const item = match.get(position.index) || {
|
||||
score: 0,
|
||||
ranges: [],
|
||||
const normalizedScore = (score - minScore) / (maxScore - minScore);
|
||||
const match = matched.get(nid) || {
|
||||
score: [] as number[],
|
||||
positions: new Map(),
|
||||
};
|
||||
item.score += normalizedScore;
|
||||
item.ranges.push(...position.ranges);
|
||||
match.set(position.index, item);
|
||||
match.score.push(normalizedScore);
|
||||
const ranges = match.positions.get(position.index) || [];
|
||||
ranges.push(...position.ranges);
|
||||
match.positions.set(position.index, ranges);
|
||||
matched.set(nid, match);
|
||||
}
|
||||
}
|
||||
const match = new Match();
|
||||
for (const [nid, items] of matched) {
|
||||
if (items.size === 0) {
|
||||
break;
|
||||
for (const [nid, { score, positions }] of matched) {
|
||||
match.addScore(
|
||||
nid,
|
||||
score.reduce((acc, s) => acc + s, 0)
|
||||
);
|
||||
|
||||
for (const [index, ranges] of positions) {
|
||||
match.addHighlighter(nid, this.fieldKey, index, ranges);
|
||||
}
|
||||
let highestScore = -1;
|
||||
let highestIndex = -1;
|
||||
let highestRanges: [number, number][] = [];
|
||||
for (const [index, { score, ranges }] of items) {
|
||||
if (score > highestScore) {
|
||||
highestScore = score;
|
||||
highestIndex = index;
|
||||
highestRanges = ranges;
|
||||
}
|
||||
}
|
||||
match.addScore(nid, highestScore);
|
||||
match.addHighlighter(nid, this.fieldKey, highestIndex, highestRanges);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/admin",
|
||||
"version": "0.16.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@affine/core": "workspace:*",
|
||||
@@ -34,7 +34,6 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.1",
|
||||
"@sentry/react": "^8.9.0",
|
||||
"@tanstack/react-table": "^8.19.3",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.1.5",
|
||||
|
||||
@@ -23,8 +23,8 @@ const Redirect = function Redirect() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/admin/accounts')) {
|
||||
navigate('/admin/accounts', { replace: true });
|
||||
if (!location.pathname.startsWith('/admin')) {
|
||||
navigate('/admin', { replace: true });
|
||||
}
|
||||
}, [location, navigate]);
|
||||
return null;
|
||||
@@ -41,31 +41,15 @@ export const router = _createBrowserRouter(
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
element: <Redirect />,
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts',
|
||||
lazy: () => import('./modules/accounts'),
|
||||
lazy: () => import('./modules/home'),
|
||||
},
|
||||
{
|
||||
path: '/admin/auth',
|
||||
lazy: () => import('./modules/auth'),
|
||||
},
|
||||
{
|
||||
path: '/admin/ai',
|
||||
lazy: () => import('./modules/ai'),
|
||||
},
|
||||
{
|
||||
path: '/admin/setup',
|
||||
lazy: () => import('./modules/setup'),
|
||||
},
|
||||
{
|
||||
path: '/admin/config',
|
||||
lazy: () => import('./modules/config'),
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
lazy: () => import('./modules/settings'),
|
||||
path: '/admin/users',
|
||||
lazy: () => import('./modules/users'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -52,30 +52,23 @@ interface SheetContentProps
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps & { withoutCloseButton?: boolean }
|
||||
>(
|
||||
(
|
||||
{ side = 'right', className, children, withoutCloseButton, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!withoutCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import type { UserType } from '@affine/graphql';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
MailWarningIcon,
|
||||
UnlockIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
const StatusItem = ({
|
||||
condition,
|
||||
IconTrue,
|
||||
IconFalse,
|
||||
textTrue,
|
||||
textFalse,
|
||||
}: {
|
||||
condition: boolean | null;
|
||||
IconTrue: ReactNode;
|
||||
IconFalse: ReactNode;
|
||||
textTrue: string;
|
||||
textFalse: string;
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-2 items-center',
|
||||
!condition ? 'text-red-500 opacity-100' : 'opacity-25'
|
||||
)}
|
||||
>
|
||||
{condition ? (
|
||||
<>
|
||||
{IconTrue}
|
||||
{textTrue}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{IconFalse}
|
||||
{textFalse}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
accessorKey: 'info',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-3 items-center max-w-[50vw] overflow-hidden">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<UserIcon size={20} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
|
||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||
<span>{row.original.name}</span>{' '}
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-medium opacity-50 max-w-full overflow-hidden">
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||
<div className="flex justify-end opacity-25">{user.id}</div>
|
||||
<div className="flex gap-3 items-center justify-end">
|
||||
<StatusItem
|
||||
condition={user.hasPassword}
|
||||
IconTrue={<LockIcon size={10} />}
|
||||
IconFalse={<UnlockIcon size={10} />}
|
||||
textTrue="Password Set"
|
||||
textFalse="No Password"
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
condition={user.emailVerified}
|
||||
IconTrue={<MailIcon size={10} />}
|
||||
IconFalse={<MailWarningIcon size={10} />}
|
||||
textTrue="Email Verified"
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableRowActions user={user} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useTransition } from 'react';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// to handle the error: a component suspended while responding to synchronous input.
|
||||
// This will cause the UI to be replaced with a loading indicator.
|
||||
// To fix, updates that suspend should be wrapped with startTransition.
|
||||
const onPageSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
startTransition(() => {
|
||||
table.setPageSize(Number(value));
|
||||
});
|
||||
},
|
||||
[table]
|
||||
);
|
||||
const handleFirstPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(0);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.previousPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleNextPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.nextPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleLastPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between md:px-2">
|
||||
<div className="flex items-center md:space-x-2">
|
||||
<p className="text-sm font-medium max-md:hidden">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={onPageSizeChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 40, 80].map(pageSize => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleFirstPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleNextPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleLastPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import {
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import type { UserType } from '../schema';
|
||||
import { DeleteAccountDialog } from './delete-account';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { ResetPasswordDialog } from './reset-password';
|
||||
import { useDeleteUser, useResetUserPassword } from './use-user-management';
|
||||
import { UpdateUserForm } from './user-form';
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const { setRightPanelContent, openPanel, isOpen, closePanel } =
|
||||
useRightPanel();
|
||||
|
||||
const deleteUser = useDeleteUser();
|
||||
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
|
||||
|
||||
const openResetPasswordDialog = useCallback(() => {
|
||||
onResetPassword(user.id, () => setResetPasswordDialogOpen(true));
|
||||
}, [onResetPassword, user.id]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(resetPasswordLink)
|
||||
.then(() => {
|
||||
toast('Reset password link copied to clipboard');
|
||||
setResetPasswordDialogOpen(false);
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to copy reset password link: ' + e.message);
|
||||
});
|
||||
}, [resetPasswordLink]);
|
||||
|
||||
const onDeleting = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteUser(user.id, onDeleting);
|
||||
}, [deleteUser, onDeleting, user.id]);
|
||||
|
||||
const openDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChangesCancel = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(
|
||||
<UpdateUserForm
|
||||
user={user}
|
||||
onComplete={closePanel}
|
||||
onResetPassword={openResetPasswordDialog}
|
||||
onDeleteAccount={openDeleteDialog}
|
||||
/>
|
||||
);
|
||||
if (discardDialogOpen) {
|
||||
handleDiscardChangesCancel();
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [
|
||||
closePanel,
|
||||
discardDialogOpen,
|
||||
handleDiscardChangesCancel,
|
||||
isOpen,
|
||||
openDeleteDialog,
|
||||
openPanel,
|
||||
openResetPasswordDialog,
|
||||
setRightPanelContent,
|
||||
user,
|
||||
]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (isOpen) {
|
||||
setDiscardDialogOpen(true);
|
||||
} else {
|
||||
handleConfirm();
|
||||
}
|
||||
}, [handleConfirm, isOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<MoreVerticalIcon size={20} />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
||||
<div className="px-2 py-[6px] text-sm font-semibold overflow-hidden text-ellipsis text-nowrap">
|
||||
{user.name}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon size={16} /> Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
>
|
||||
<SettingsIcon size={16} /> Edit
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDeleteDialog}
|
||||
>
|
||||
<TrashIcon size={16} /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteAccountDialog
|
||||
email={user.email}
|
||||
open={deleteDialogOpen}
|
||||
onClose={closeDeleteDialog}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<ResetPasswordDialog
|
||||
link={resetPasswordLink}
|
||||
open={resetPasswordDialogOpen}
|
||||
onOpenChange={setResetPasswordDialogOpen}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<DiscardChanges
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={setDiscardDialogOpen}
|
||||
onClose={handleDiscardChangesCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getUserByEmailQuery } from '@affine/graphql';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import type { SetStateAction } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
data: TData[];
|
||||
setDataTable: (data: TData[]) => void;
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
data,
|
||||
setDataTable,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const [value, setValue] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const debouncedValue = useDebouncedValue(value, 500);
|
||||
const { setRightPanelContent, openPanel, closePanel, isOpen } =
|
||||
useRightPanel();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(<CreateUserForm onComplete={closePanel} />);
|
||||
if (dialogOpen) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [setRightPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
|
||||
|
||||
const result = useQuery({
|
||||
query: getUserByEmailQuery,
|
||||
variables: {
|
||||
email: value,
|
||||
},
|
||||
}).data.userByEmail;
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
if (!debouncedValue) {
|
||||
setDataTable(data);
|
||||
} else if (result) {
|
||||
setDataTable([result as TData]);
|
||||
} else {
|
||||
setDataTable([]);
|
||||
}
|
||||
});
|
||||
}, [data, debouncedValue, result, setDataTable, value]);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: { currentTarget: { value: SetStateAction<string> } }) => {
|
||||
startTransition(() => {
|
||||
setValue(e.currentTarget.value);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenConfirm = useCallback(() => {
|
||||
if (isOpen) {
|
||||
return setDialogOpen(true);
|
||||
}
|
||||
return handleConfirm();
|
||||
}, [handleConfirm, isOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search Email"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
className="h-10 w-full mr-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="px-4 py-2 space-x-[10px] text-sm font-medium"
|
||||
onClick={handleOpenConfirm}
|
||||
>
|
||||
<PlusIcon size={20} /> <span>Add User</span>
|
||||
</Button>
|
||||
<DiscardChanges
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onClose={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getUsersCountQuery } from '@affine/graphql';
|
||||
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { DataTablePagination } from './data-table-pagination';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const {
|
||||
data: { usersCount },
|
||||
} = useQuery({
|
||||
query: getUsersCountQuery,
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState(data);
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: usersCount,
|
||||
enableFilters: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTableData(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-5 px-6 h-full">
|
||||
<DataTableToolbar setDataTable={setTableData} data={data} />
|
||||
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const DeleteAccountDialog = ({
|
||||
email,
|
||||
open,
|
||||
onClose,
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
}: {
|
||||
email: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const handleInput = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(event.target.value);
|
||||
},
|
||||
[setInput]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Account ?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-bold">{email}</span> will be permanently
|
||||
deleted. This operation is irreversible. Please proceed with
|
||||
caution.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
export const DiscardChanges = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Changes to this user will not be saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end items-center w-full space-x-4">
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} variant="destructive">
|
||||
<span>Discard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export const Logo = () => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.6172 16.2657C18.314 15.7224 17.8091 14.8204 17.3102 13.9295C17.1589 13.6591 17.0086 13.3904 16.8644 13.1326C16.5679 12.6025 16.2978 12.1191 16.1052 11.7741C14.7688 9.38998 12.1376 4.66958 10.823 2.33541C10.418 1.68481 9.47943 1.73636 9.13092 2.4101C8.73553 3.1175 8.3004 3.89538 7.84081 4.71744C7.69509 4.97831 7.54631 5.24392 7.396 5.51268C5.48122 8.93556 3.24035 12.9423 1.64403 15.7961C1.5625 15.9486 1.41067 16.1974 1.33475 16.362C1.20176 16.6591 1.22775 17.0294 1.39538 17.304C1.58441 17.629 1.93802 17.8073 2.29927 17.7889C2.73389 17.7889 3.65561 17.7884 4.84738 17.7889C5.13016 17.7889 5.42823 17.7889 5.73853 17.7889C9.88246 17.7889 16.2127 17.7915 17.7663 17.7889C18.5209 17.7905 18.9942 16.9363 18.6182 16.2652L18.6172 16.2657ZM9.69699 13.2342L8.93424 11.8704C8.80024 11.6305 8.96787 11.3307 9.23588 11.3307H10.7614C11.0299 11.3307 11.1975 11.6305 11.063 11.8704L10.3003 13.2342C10.1663 13.474 9.83099 13.474 9.69648 13.2342H9.69699ZM8.41912 10.6943C8.35594 10.5281 8.30142 10.3593 8.25658 10.1878L10.7802 10.6943H8.41912ZM9.57165 14.2824C9.46414 14.4223 9.3495 14.5553 9.22823 14.6816L8.39109 12.1723L9.57114 14.2824H9.57165ZM12.0061 11.458C12.1768 11.4843 12.346 11.5206 12.5121 11.5658L10.8256 13.5687L12.0061 11.458ZM8.10117 9.33318C8.07417 9.07967 8.06245 8.82353 8.06347 8.56687L11.3962 10.2452L8.10067 9.33371L8.10117 9.33318ZM7.70579 11.8456L8.58828 15.2459C8.38905 15.3969 8.18015 15.5357 7.96411 15.663L7.70528 11.8456H7.70579ZM13.3069 11.8546C13.5332 11.9571 13.7538 12.075 13.9688 12.2043L10.8944 14.345L13.3069 11.8546ZM8.1399 7.48447C8.20104 7.01847 8.2953 6.55932 8.40943 6.1191L13.4725 10.6623L8.14041 7.48447H8.1399ZM7.01793 16.1369C6.59656 16.3152 6.16449 16.4603 5.73802 16.5781L7.01793 9.78129V16.1369ZM14.8386 12.8134C15.1988 13.1011 15.5371 13.4151 15.8494 13.737L9.50643 15.9912L14.8386 12.8134ZM10.2203 3.56456C11.1537 5.23655 12.509 7.66118 13.8002 9.96905L8.97959 4.99304C9.26288 4.48707 9.5314 4.00688 9.77902 3.56351C9.87736 3.38837 10.1219 3.38837 10.2203 3.56351V3.56456ZM2.69109 16.2358C2.95655 15.7629 3.32137 15.1144 3.40238 14.9651C4.17074 13.5913 5.20557 11.7415 6.27454 9.8302L4.50906 16.6307C3.87674 16.6307 3.33156 16.6307 2.91171 16.6307C2.71555 16.6307 2.59275 16.4114 2.69109 16.2363V16.2358ZM17.0871 16.6318C15.6151 16.6318 12.7572 16.6318 9.91965 16.6318L16.5083 14.8094C16.8537 15.4268 17.1304 15.9212 17.3077 16.2379C17.406 16.413 17.2832 16.6318 17.0876 16.6318H17.0871Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user