diff --git a/.github/actions/deploy/deploy.mjs b/.github/actions/deploy/deploy.mjs index 126d3b111d..204d8471ad 100644 --- a/.github/actions/deploy/deploy.mjs +++ b/.github/actions/deploy/deploy.mjs @@ -28,6 +28,7 @@ const { REDIS_PASSWORD, STRIPE_API_KEY, STRIPE_WEBHOOK_KEY, + STATIC_IP_NAME, } = process.env; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -35,17 +36,13 @@ const buildType = BUILD_TYPE || 'canary'; const isProduction = buildType === 'stable'; const isBeta = buildType === 'beta'; +const isInternal = buildType === 'internal'; const createHelmCommand = ({ isDryRun }) => { const flag = isDryRun ? '--dry-run' : '--atomic'; const imageTag = `${buildType}-${GIT_SHORT_HASH}`; - const staticIpName = isProduction - ? 'affine-cluster-production' - : isBeta - ? 'affine-cluster-beta' - : 'affine-cluster-dev'; const redisAndPostgres = - isProduction || isBeta + isProduction || isBeta || isInternal ? [ `--set-string global.database.url=${DATABASE_URL}`, `--set-string global.database.user=${DATABASE_USERNAME}`, @@ -59,26 +56,32 @@ const createHelmCommand = ({ isDryRun }) => { ] : []; const serviceAnnotations = - isProduction || isBeta + isProduction || isBeta || isInternal ? [ `--set-json web.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`, - `--set-json graphql.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`, `--set-json graphql.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`, - `--set-json sync.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`, `--set-json sync.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`, + `--set-json cloud-sql-proxy.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`, + `--set-json cloud-sql-proxy.nodeSelector=\"{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }\"`, ] : []; const webReplicaCount = isProduction ? 3 : isBeta ? 2 : 2; const graphqlReplicaCount = isProduction ? 10 : isBeta ? 5 : 2; const syncReplicaCount = isProduction ? 10 : isBeta ? 5 : 2; - const namespace = isProduction ? 'production' : isBeta ? 'beta' : 'dev'; + const namespace = isProduction + ? 'production' + : isBeta + ? 'beta' + : isInternal + ? 'internal' + : 'dev'; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const host = DEPLOY_HOST || CANARY_DEPLOY_HOST; const deployCommand = [ `helm upgrade --install affine .github/helm/affine`, `--namespace ${namespace}`, `--set global.ingress.enabled=true`, - `--set-json global.ingress.annotations=\"{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${staticIpName}\\" }\"`, + `--set-json global.ingress.annotations=\"{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }\"`, `--set-string global.ingress.host="${host}"`, `--set-string global.version="${APP_VERSION}"`, ...redisAndPostgres, diff --git a/.github/helm/affine/Chart.yaml b/.github/helm/affine/Chart.yaml index 281ccd9bd0..6f0ef1afa1 100644 --- a/.github/helm/affine/Chart.yaml +++ b/.github/helm/affine/Chart.yaml @@ -3,4 +3,4 @@ name: affine description: AFFiNE cloud chart type: application version: 0.0.0 -appVersion: "0.10.3-canary.2" +appVersion: "0.11.0" diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/.helmignore b/.github/helm/affine/charts/gcloud-sql-proxy/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml new file mode 100644 index 0000000000..f1038c707d --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: cloud-sql-proxy +description: Google Cloud SQL Proxy +type: application +version: 0.0.0 +appVersion: "2.8.1" diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt b/.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt new file mode 100644 index 0000000000..b7cb03936a --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt @@ -0,0 +1,18 @@ +{{- if .Values.global.database.gcloud.enabled -}} +1. Get the application URL by running these commands: +{{- if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "gcloud-sql-proxy.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "gcloud-sql-proxy.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "gcloud-sql-proxy.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "gcloud-sql-proxy.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl b/.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl new file mode 100644 index 0000000000..8d447acbfd --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gcloud-sql-proxy.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "gcloud-sql-proxy.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gcloud-sql-proxy.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gcloud-sql-proxy.labels" -}} +helm.sh/chart: {{ include "gcloud-sql-proxy.chart" . }} +{{ include "gcloud-sql-proxy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gcloud-sql-proxy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gcloud-sql-proxy.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "gcloud-sql-proxy.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "gcloud-sql-proxy.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml new file mode 100644 index 0000000000..26d7832554 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml @@ -0,0 +1,132 @@ +{{- if .Values.global.database.gcloud.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gcloud-sql-proxy.fullname" . }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "gcloud-sql-proxy.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "gcloud-sql-proxy.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - "--address" + - "0.0.0.0" + - "--structured-logs" + - "--auto-iam-authn" + - "{{ .Values.global.database.gcloud.connectionName }}" + env: + # Enable HTTP healthchecks on port 9801. This enables /liveness, + # /readiness and /startup health check endpoints. Allow connections + # listen for connections on any interface (0.0.0.0) so that the + # k8s management components can reach these endpoints. + - name: CSQL_PROXY_HEALTH_CHECK + value: "true" + - name: CSQL_PROXY_HTTP_PORT + value: "9801" + - name: CSQL_PROXY_HTTP_ADDRESS + value: 0.0.0.0 + ports: + - name: cloud-sql-proxy + containerPort: {{ .Values.global.database.gcloud.proxyPort }} + protocol: TCP + - containerPort: 9801 + protocol: TCP + # The /startup probe returns OK when the proxy is ready to receive + # connections from the application. In this example, k8s will check + # once a second for 60 seconds. + startupProbe: + failureThreshold: 60 + httpGet: + path: /startup + port: 9801 + scheme: HTTP + periodSeconds: 1 + successThreshold: 1 + timeoutSeconds: 10 + # The /liveness probe returns OK as soon as the proxy application has + # begun its startup process and continues to return OK until the + # process stops. + livenessProbe: + failureThreshold: 3 + httpGet: + path: /liveness + port: 9801 + scheme: HTTP + # The probe will be checked every 10 seconds. + periodSeconds: 10 + # Number of times the probe is allowed to fail before the transition + # from healthy to failure state. + # + # If periodSeconds = 60, 5 tries will result in five minutes of + # checks. The proxy starts to refresh a certificate five minutes + # before its expiration. If those five minutes lapse without a + # successful refresh, the liveness probe will fail and the pod will be + # restarted. + successThreshold: 1 + # The probe will fail if it does not respond in 10 seconds + timeoutSeconds: 10 + readinessProbe: + # The /readiness probe returns OK when the proxy can establish + # a new connections to its databases. + httpGet: + path: /readiness + port: 9801 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 10 + # Number of times the probe must report success to transition from failure to healthy state. + # Defaults to 1 for readiness probe. + successThreshold: 1 + failureThreshold: 6 + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml new file mode 100644 index 0000000000..b7a0c96cf7 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.global.database.gcloud.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gcloud-sql-proxy.fullname" . }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.global.database.port }} + targetPort: cloud-sql-proxy + protocol: TCP + name: cloud-sql-proxy + selector: + {{- include "gcloud-sql-proxy.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml new file mode 100644 index 0000000000..5e9bfa2ada --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml @@ -0,0 +1,15 @@ +{{- if .Values.global.database.gcloud.enabled -}} +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "gcloud-sql-proxy.serviceAccountName" . }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..c6138cc299 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml @@ -0,0 +1,17 @@ +{{- if .Values.global.database.gcloud.enabled -}} +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "gcloud-sql-proxy.fullname" . }}-test-connection" + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "gcloud-sql-proxy.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/values.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/values.yaml new file mode 100644 index 0000000000..947365fe35 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/values.yaml @@ -0,0 +1,40 @@ +replicaCount: 3 + +image: + # the tag is defined as chart appVersion. + repository: gcr.io/cloud-sql-connectors/cloud-sql-proxy + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 2000 + +securityContext: + runAsNonRoot: true + +service: + type: ClusterIP + port: 5432 + +resources: + limits: + memory: "4Gi" + cpu: "2" + +volumes: [] +volumeMounts: [] +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/.github/helm/affine/charts/graphql/Chart.yaml b/.github/helm/affine/charts/graphql/Chart.yaml index 8a66ffa1ae..24843dd64f 100644 --- a/.github/helm/affine/charts/graphql/Chart.yaml +++ b/.github/helm/affine/charts/graphql/Chart.yaml @@ -3,4 +3,9 @@ name: graphql description: AFFiNE GraphQL server type: application version: 0.0.0 -appVersion: "0.10.3-canary.2" +appVersion: "0.11.0" +dependencies: + - name: gcloud-sql-proxy + version: 0.0.0 + repository: "file://../gcloud-sql-proxy" + condition: .global.database.gcloud.enabled diff --git a/.github/helm/affine/charts/graphql/templates/deployment.yaml b/.github/helm/affine/charts/graphql/templates/deployment.yaml index 593824db68..be14730988 100644 --- a/.github/helm/affine/charts/graphql/templates/deployment.yaml +++ b/.github/helm/affine/charts/graphql/templates/deployment.yaml @@ -189,20 +189,6 @@ spec: initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{ if .Values.global.database.gcloud.enabled }} - - name: cloud-sql-proxy - image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.6.0 - args: - - "--structured-logs" - - "--auto-iam-authn" - - "{{ .Values.global.database.gcloud.connectionName }}" - securityContext: - runAsNonRoot: true - resources: - requests: - memory: "2Gi" - cpu: "1" - {{ end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/.github/helm/affine/charts/sync/Chart.yaml b/.github/helm/affine/charts/sync/Chart.yaml index 071318a561..35cb48acca 100644 --- a/.github/helm/affine/charts/sync/Chart.yaml +++ b/.github/helm/affine/charts/sync/Chart.yaml @@ -1,6 +1,11 @@ apiVersion: v2 name: sync -description: A Helm chart for Kubernetes +description: AFFiNE Sync Server type: application version: 0.0.0 -appVersion: "0.10.3-canary.2" +appVersion: "0.11.0" +dependencies: + - name: gcloud-sql-proxy + version: 0.0.0 + repository: "file://../gcloud-sql-proxy" + condition: .global.database.gcloud.enabled diff --git a/.github/helm/affine/charts/sync/templates/deployment.yaml b/.github/helm/affine/charts/sync/templates/deployment.yaml index f1b6c4a99c..f7e06cce7b 100644 --- a/.github/helm/affine/charts/sync/templates/deployment.yaml +++ b/.github/helm/affine/charts/sync/templates/deployment.yaml @@ -82,20 +82,6 @@ spec: initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{ if .Values.global.database.gcloud.enabled }} - - name: cloud-sql-proxy - image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.6.0 - args: - - "--structured-logs" - - "--auto-iam-authn" - - "{{ .Values.global.database.gcloud.connectionName }}" - securityContext: - runAsNonRoot: true - resources: - requests: - memory: "2Gi" - cpu: "1" - {{ end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/.github/helm/affine/values.yaml b/.github/helm/affine/values.yaml index b545130912..7326f7029a 100644 --- a/.github/helm/affine/values.yaml +++ b/.github/helm/affine/values.yaml @@ -16,6 +16,8 @@ global: cloudSqlInternal: '' connectionName: '' serviceAccount: '' + cloudProxyReplicas: 3 + proxyPort: '5432' redis: enabled: true host: 'redis-master' diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index cb26bc18c1..d95161ad8f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -45,7 +45,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -71,7 +71,7 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 lint: name: Lint runs-on: ubuntu-latest @@ -80,7 +80,7 @@ jobs: - uses: actions/checkout@v4 - name: Run oxlint # oxlint is fast, so wrong code will fail quickly - run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'])") + run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'].replace('oxlint', 'oxlint@' + require('./package.json').devDependencies.oxlint))") - name: Setup Node.js uses: ./.github/actions/setup-node with: @@ -374,7 +374,9 @@ jobs: DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - name: Run init-db script - run: yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts + run: | + yarn workspace @affine/server data-migration run + yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts env: DATABASE_URL: postgresql://affine:affine@localhost:5432/affine @@ -464,7 +466,9 @@ jobs: DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - name: Run init-db script - run: yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts + run: | + yarn workspace @affine/server data-migration run + yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts - name: Download storage.node uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52e03ae9c8..d0ec8394af 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: flavor: - description: 'Build type (canary, beta, or stable)' + description: 'Build type (canary, beta, or stable or internal)' type: string default: canary @@ -249,3 +249,4 @@ jobs: CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }} STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }} + STATIC_IP_NAME: ${{ secrets.STATIC_IP_NAME }} diff --git a/.github/workflows/workers.yml b/.github/workflows/workers.yml index b51fc50926..f079df253a 100644 --- a/.github/workflows/workers.yml +++ b/.github/workflows/workers.yml @@ -11,7 +11,7 @@ jobs: deploy: runs-on: ubuntu-latest name: Deploy - environment: production + environment: stable steps: - uses: actions/checkout@v4 - name: Publish diff --git a/Cargo.lock b/Cargo.lock index 6e263c1c4d..2313e7e1c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3318,18 +3318,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.7.26" +version = "0.7.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" +checksum = "1c4061bedbb353041c12f413700357bec76df2c7e2ca8e4df8bac24c6bf68e3d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.26" +version = "0.7.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" +checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" dependencies = [ "proc-macro2", "quote", diff --git a/package.json b/package.json index da969d109c..19e34ecfa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@affine/monorepo", - "version": "0.10.3", + "version": "0.11.0", "private": true, "author": "toeverything", "license": "MIT", @@ -33,7 +33,7 @@ "lint:eslint:fix": "yarn lint:eslint --fix", "lint:prettier": "prettier --ignore-unknown --cache --check .", "lint:prettier:fix": "prettier --ignore-unknown --cache --write .", - "lint:ox": "oxlint --deny-warnings --import-plugin -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment", + "lint:ox": "oxlint --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment", "lint": "yarn lint:eslint && yarn lint:prettier", "lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix", "test": "vitest --run", @@ -78,8 +78,8 @@ "@vanilla-extract/vite-plugin": "^3.9.2", "@vanilla-extract/webpack-plugin": "^2.3.1", "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-istanbul": "0.34.6", - "@vitest/ui": "0.34.6", + "@vitest/coverage-istanbul": "1.0.4", + "@vitest/ui": "1.0.4", "electron": "^27.1.0", "eslint": "^8.54.0", "eslint-config-prettier": "^9.0.0", @@ -100,7 +100,7 @@ "nx": "^17.1.3", "nx-cloud": "^16.5.2", "nyc": "^15.1.0", - "oxlint": "^0.0.18", + "oxlint": "0.0.21", "prettier": "^3.1.0", "semver": "^7.5.4", "serve": "^14.2.1", @@ -111,7 +111,7 @@ "vite-plugin-istanbul": "^5.0.0", "vite-plugin-static-copy": "^1.0.0", "vite-tsconfig-paths": "^4.2.1", - "vitest": "0.34.6", + "vitest": "1.0.4", "vitest-fetch-mock": "^0.2.2", "vitest-mock-extended": "^1.3.1" }, diff --git a/packages/backend/server/migrations/20231030104009_user_features/migration.sql b/packages/backend/server/migrations/20231030104009_user_features/migration.sql new file mode 100644 index 0000000000..5d273a4eb8 --- /dev/null +++ b/packages/backend/server/migrations/20231030104009_user_features/migration.sql @@ -0,0 +1,45 @@ +/* + Warnings: + + - You are about to drop the `user_feature_gates` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "user_feature_gates" DROP CONSTRAINT "user_feature_gates_user_id_fkey"; + +-- DropTable +DROP TABLE "user_feature_gates"; + +-- CreateTable +CREATE TABLE "user_features" ( + "id" SERIAL NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "feature_id" INTEGER NOT NULL, + "reason" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expired_at" TIMESTAMPTZ(6), + "activated" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "user_features_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "features" ( + "id" SERIAL NOT NULL, + "feature" VARCHAR NOT NULL, + "version" INTEGER NOT NULL DEFAULT 0, + "type" INTEGER NOT NULL, + "configs" JSON NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "features_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "features_feature_version_key" ON "features"("feature", "version"); + +-- AddForeignKey +ALTER TABLE "user_features" ADD CONSTRAINT "user_features_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_features" ADD CONSTRAINT "user_features_feature_id_fkey" FOREIGN KEY ("feature_id") REFERENCES "features"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 4ed47ca843..ea2ddcf98f 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -1,7 +1,7 @@ { "name": "@affine/server", "private": true, - "version": "0.10.3", + "version": "0.11.0", "description": "Affine Node.js server", "type": "module", "bin": { @@ -82,7 +82,8 @@ "socket.io": "^4.7.2", "stripe": "^14.5.0", "ws": "^8.14.2", - "yjs": "^13.6.10" + "yjs": "^13.6.10", + "zod": "^3.22.4" }, "devDependencies": { "@affine-test/kit": "workspace:*", @@ -135,7 +136,8 @@ "ENABLE_LOCAL_EMAIL": "true", "OAUTH_EMAIL_LOGIN": "noreply@toeverything.info", "OAUTH_EMAIL_PASSWORD": "affine", - "OAUTH_EMAIL_SENDER": "noreply@toeverything.info" + "OAUTH_EMAIL_SENDER": "noreply@toeverything.info", + "FEATURES_EARLY_ACCESS_PREVIEW": "false" } }, "nodemonConfig": { diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 640ad71c4a..fae3ff0b10 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -22,7 +22,7 @@ model User { accounts Account[] sessions Session[] - features UserFeatureGates[] + features UserFeatures[] customer UserStripeCustomer? subscription UserSubscription? invoices UserInvoice[] @@ -113,15 +113,48 @@ model WorkspacePageUserPermission { @@map("workspace_page_user_permissions") } -model UserFeatureGates { - id String @id @default(uuid()) @db.VarChar - userId String @map("user_id") @db.VarChar - feature String @db.VarChar - reason String @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +// feature gates is a way to enable/disable features for a user +// 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 UserFeatures { + id Int @id @default(autoincrement()) + userId String @map("user_id") @db.VarChar(36) + featureId Int @map("feature_id") @db.Integer - @@map("user_feature_gates") + // we will record the reason why the feature is enabled/disabled + // for example: + // - 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(6) + // record the quota expired time, pay plan is a subscription, so it will expired + 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) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade) + + @@map("user_features") +} + +model Features { + id Int @id @default(autoincrement()) + feature String @db.VarChar + version Int @default(0) @db.Integer + // 0: feature, 1: quota + type Int @db.Integer + // configs, define by feature conntroller + configs Json @db.Json + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + UserFeatureGates UserFeatures[] + + @@unique([feature, version]) + @@map("features") } model Account { diff --git a/packages/backend/server/scripts/init-db.ts b/packages/backend/server/scripts/init-db.ts index a43ebb4f58..8e850ea716 100644 --- a/packages/backend/server/scripts/init-db.ts +++ b/packages/backend/server/scripts/init-db.ts @@ -8,6 +8,20 @@ async function main() { data: { ...userA, password: await hash(userA.password), + features: { + create: { + reason: 'created by api sign up', + activated: true, + feature: { + connect: { + feature_version: { + feature: 'free_plan_v1', + version: 1, + }, + }, + }, + }, + }, }, }); } diff --git a/packages/backend/server/src/app.ts b/packages/backend/server/src/app.ts index afc5ab7b1a..a1f068f026 100644 --- a/packages/backend/server/src/app.ts +++ b/packages/backend/server/src/app.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; -import { CacheModule } from './cache'; +import { CacheInterceptor, CacheModule } from './cache'; import { ConfigModule } from './config'; import { EventModule } from './event'; import { BusinessModules } from './modules'; @@ -23,6 +24,12 @@ const BasicModules = [ ]; @Module({ + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: CacheInterceptor, + }, + ], imports: [...BasicModules, ...BusinessModules], controllers: [AppController], }) diff --git a/packages/backend/server/src/cache/index.ts b/packages/backend/server/src/cache/index.ts index 621407f031..3a1dab5310 100644 --- a/packages/backend/server/src/cache/index.ts +++ b/packages/backend/server/src/cache/index.ts @@ -22,3 +22,5 @@ const CacheProvider: FactoryProvider = { }) export class CacheModule {} export { LocalCache as Cache }; + +export { CacheInterceptor, MakeCache, PreventCache } from './interceptor'; diff --git a/packages/backend/server/src/cache/interceptor.ts b/packages/backend/server/src/cache/interceptor.ts new file mode 100644 index 0000000000..8cf078e18f --- /dev/null +++ b/packages/backend/server/src/cache/interceptor.ts @@ -0,0 +1,99 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; +import { mergeMap, Observable, of } from 'rxjs'; + +import { LocalCache } from './cache'; + +export const MakeCache = (key: string[], args?: string[]) => + SetMetadata('cacheKey', [key, args]); +export const PreventCache = (key: string[], args?: string[]) => + SetMetadata('preventCache', [key, args]); + +type CacheConfig = [string[], string[]?]; + +@Injectable() +export class CacheInterceptor implements NestInterceptor { + private readonly logger = new Logger(CacheInterceptor.name); + constructor( + private readonly reflector: Reflector, + private readonly cache: LocalCache + ) {} + async intercept( + ctx: ExecutionContext, + next: CallHandler + ): Promise> { + const key = this.reflector.get( + 'cacheKey', + ctx.getHandler() + ); + const preventKey = this.reflector.get( + 'preventCache', + ctx.getHandler() + ); + + if (preventKey) { + this.logger.debug(`prevent cache: ${JSON.stringify(preventKey)}`); + const key = await this.getCacheKey(ctx, preventKey); + if (key) { + await this.cache.delete(key); + } + + return next.handle(); + } else if (!key) { + return next.handle(); + } + + const cacheKey = await this.getCacheKey(ctx, key); + + if (!cacheKey) { + return next.handle(); + } + + const cachedData = await this.cache.get(cacheKey); + + if (cachedData) { + this.logger.debug('cache hit', cacheKey, cachedData); + return of(cachedData); + } else { + return next.handle().pipe( + mergeMap(async result => { + this.logger.debug('cache miss', cacheKey, result); + await this.cache.set(cacheKey, result); + + return result; + }) + ); + } + } + + private async getCacheKey( + ctx: ExecutionContext, + config: CacheConfig + ): Promise { + const [key, params] = config; + + if (!params) { + return key.join(':'); + } else if (ctx.getType() === 'graphql') { + const args = GqlExecutionContext.create(ctx).getArgs(); + const cacheKey = params + .map(name => args[name]) + .filter(v => v) + .join(':'); + if (cacheKey) { + return [...key, cacheKey].join(':'); + } else { + return key.join(':'); + } + } + return null; + } +} diff --git a/packages/backend/server/src/config/def.ts b/packages/backend/server/src/config/def.ts index 1b9a065d9a..145e1b3b5d 100644 --- a/packages/backend/server/src/config/def.ts +++ b/packages/backend/server/src/config/def.ts @@ -16,6 +16,8 @@ export enum ExternalAccount { firebase = 'firebase', } +export type ServerFlavor = 'allinone' | 'graphql' | 'sync' | 'selfhosted'; + type EnvConfigType = 'string' | 'int' | 'float' | 'boolean'; type ConfigPaths = LeafPaths< Omit< @@ -186,11 +188,6 @@ export interface AFFiNEConfig { fs: { path: string; }; - /** - * default storage quota - * @default 10 * 1024 * 1024 * 1024 (10GB) - */ - quota: number; }; /** @@ -345,6 +342,11 @@ export interface AFFiNEConfig { doc: { manager: { + /** + * Whether auto merge updates into doc snapshot. + */ + enableUpdateAutoMerging: boolean; + /** * How often the [DocManager] will start a new turn of merging pending updates into doc snapshot. * diff --git a/packages/backend/server/src/config/default.ts b/packages/backend/server/src/config/default.ts index 04e6aaacc3..aa2f0ba76f 100644 --- a/packages/backend/server/src/config/default.ts +++ b/packages/backend/server/src/config/default.ts @@ -7,9 +7,12 @@ import { join } from 'node:path'; import parse from 'parse-duration'; import pkg from '../../package.json' assert { type: 'json' }; -import type { AFFiNEConfig } from './def'; +import type { AFFiNEConfig, ServerFlavor } from './def'; import { applyEnvToConfig } from './env'; +export const SERVER_FLAVOR = (process.env.SERVER_FLAVOR ?? + 'allinone') as ServerFlavor; + // Don't use this in production export const examplePrivateKey = `-----BEGIN EC PRIVATE KEY----- MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49 @@ -55,7 +58,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { AFFINE_SERVER_HOST: 'host', AFFINE_SERVER_SUB_PATH: 'path', AFFINE_ENV: 'affineEnv', - AFFINE_FREE_USER_QUOTA: 'objectStorage.quota', DATABASE_URL: 'db.url', ENABLE_R2_OBJECT_STORAGE: ['objectStorage.r2.enabled', 'boolean'], R2_OBJECT_STORAGE_ACCOUNT_ID: 'objectStorage.r2.accountId', @@ -189,8 +191,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { fs: { path: join(homedir(), '.affine-storage'), }, - // 10GB - quota: 10 * 1024 * 1024 * 1024, }, rateLimiter: { ttl: 60, @@ -206,6 +206,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { }, doc: { manager: { + enableUpdateAutoMerging: SERVER_FLAVOR !== 'sync', updatePollInterval: 3000, experimentalMergeWithJwstCodec: false, }, diff --git a/packages/backend/server/src/config/index.ts b/packages/backend/server/src/config/index.ts index 812cbe6e1e..cdd76a91bb 100644 --- a/packages/backend/server/src/config/index.ts +++ b/packages/backend/server/src/config/index.ts @@ -73,3 +73,4 @@ export class ConfigModule { } export type { AFFiNEConfig } from './def'; +export { SERVER_FLAVOR } from './default'; diff --git a/packages/backend/server/src/data/commands/run.ts b/packages/backend/server/src/data/commands/run.ts index f58c655fca..97b3c3098d 100644 --- a/packages/backend/server/src/data/commands/run.ts +++ b/packages/backend/server/src/data/commands/run.ts @@ -14,7 +14,7 @@ interface Migration { down: (db: PrismaService) => Promise; } -async function collectMigrations(): Promise { +export async function collectMigrations(): Promise { const folder = join(fileURLToPath(import.meta.url), '../../migrations'); const migrationFiles = readdirSync(folder) @@ -64,35 +64,8 @@ export class RunCommand extends CommandRunner { continue; } - this.logger.log(`Running ${migration.name}...`); - const record = await this.db.dataMigration.create({ - data: { - name: migration.name, - startedAt: new Date(), - }, - }); + await this.runMigration(migration); - try { - await migration.up(this.db); - } catch (e) { - await this.db.dataMigration.delete({ - where: { - id: record.id, - }, - }); - await migration.down(this.db); - this.logger.error('Failed to run data migration', e); - process.exit(1); - } - - await this.db.dataMigration.update({ - where: { - id: record.id, - }, - data: { - finishedAt: new Date(), - }, - }); done.push(migration); } @@ -101,6 +74,56 @@ export class RunCommand extends CommandRunner { this.logger.log(` ✔ ${migration.name}`); }); } + + async runOne(name: string) { + const migrations = await collectMigrations(); + const migration = migrations.find(m => m.name === name); + + if (!migration) { + throw new Error(`Unknown migration name: ${name}.`); + } + const exists = await this.db.dataMigration.count({ + where: { + name: migration.name, + }, + }); + + if (exists) return; + + await this.runMigration(migration); + } + + private async runMigration(migration: Migration) { + this.logger.log(`Running ${migration.name}...`); + const record = await this.db.dataMigration.create({ + data: { + name: migration.name, + startedAt: new Date(), + }, + }); + + try { + await migration.up(this.db); + } catch (e) { + await this.db.dataMigration.delete({ + where: { + id: record.id, + }, + }); + await migration.down(this.db); + this.logger.error('Failed to run data migration', e); + process.exit(1); + } + + await this.db.dataMigration.update({ + where: { + id: record.id, + }, + data: { + finishedAt: new Date(), + }, + }); + } } @Command({ diff --git a/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts b/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts new file mode 100644 index 0000000000..b877eb18b3 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts @@ -0,0 +1,122 @@ +import { Prisma } from '@prisma/client'; + +import { + CommonFeature, + FeatureKind, + Features, + FeatureType, +} from '../../modules/features'; +import { Quotas } from '../../modules/quota/schema'; +import { PrismaService } from '../../prisma'; + +export class UserFeaturesInit1698652531198 { + // do the migration + static async up(db: PrismaService) { + // upgrade features from lower version to higher version + for (const feature of Features) { + await upsertFeature(db, feature); + } + await migrateNewFeatureTable(db); + + for (const quota of Quotas) { + await upsertFeature(db, quota); + } + } + + // revert the migration + static async down(_db: PrismaService) { + // TODO: revert the migration + } +} + +// upgrade features from lower version to higher version +async function upsertFeature( + db: PrismaService, + feature: CommonFeature +): Promise { + const hasEqualOrGreaterVersion = + (await db.features.count({ + where: { + feature: feature.feature, + version: { + gte: feature.version, + }, + }, + })) > 0; + // will not update exists version + if (!hasEqualOrGreaterVersion) { + await db.features.create({ + data: { + feature: feature.feature, + type: feature.type, + version: feature.version, + configs: feature.configs as Prisma.InputJsonValue, + }, + }); + } +} + +async function migrateNewFeatureTable(prisma: PrismaService) { + const waitingList = await prisma.newFeaturesWaitingList.findMany(); + 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, + user: { + connect: { + id: user.id, + }, + }, + feature: { + connect: { + feature_version: { + feature: FeatureType.EarlyAccess, + version: 1, + }, + type: FeatureKind.Feature, + }, + }, + }, + }) + .then(r => r.id); + } + }); + } + } + } +} diff --git a/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts b/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts new file mode 100644 index 0000000000..330b33f433 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts @@ -0,0 +1,37 @@ +import { QuotaType } from '../../modules/quota/types'; +import { PrismaService } from '../../prisma'; + +export class OldUserFeature1702620653283 { + // do the migration + static async up(db: PrismaService) { + await db.$transaction(async tx => { + const latestFreePlan = await tx.features.findFirstOrThrow({ + where: { feature: QuotaType.FreePlanV1 }, + orderBy: { version: 'desc' }, + select: { id: true }, + }); + + // find all users that don't have any features + const userIds = await db.user.findMany({ + where: { NOT: { features: { some: { NOT: { id: { gt: 0 } } } } } }, + select: { id: true }, + }); + console.log(`migrating ${userIds.join('|')} users`); + + await tx.userFeatures.createMany({ + data: userIds.map(({ id: userId }) => ({ + userId, + featureId: latestFreePlan.id, + reason: 'old user feature migration', + activated: true, + })), + }); + }); + } + + // revert the migration + // WARN: this will drop all user features + static async down(db: PrismaService) { + await db.userFeatures.deleteMany({}); + } +} diff --git a/packages/backend/server/src/event/index.ts b/packages/backend/server/src/event/index.ts index 0c70582e59..21db47d2bb 100644 --- a/packages/backend/server/src/event/index.ts +++ b/packages/backend/server/src/event/index.ts @@ -28,12 +28,10 @@ export class EventEmitter { } } -export const OnEvent = ( +export const OnEvent = RawOnEvent as ( event: Event, opts?: Parameters[1] -) => { - return RawOnEvent(event, opts); -}; +) => MethodDecorator; @Global() @Module({ diff --git a/packages/backend/server/src/graphql.module.ts b/packages/backend/server/src/graphql.module.ts index e9c86ffa35..38ded5c1df 100644 --- a/packages/backend/server/src/graphql.module.ts +++ b/packages/backend/server/src/graphql.module.ts @@ -29,6 +29,7 @@ import { GQLLoggerPlugin } from './graphql/logger-plugin'; context: ({ req, res }: { req: Request; res: Response }) => ({ req, res, + isAdminQuery: false, }), plugins: [new GQLLoggerPlugin()], }; diff --git a/packages/backend/server/src/modules/auth/mailer/mail.service.ts b/packages/backend/server/src/modules/auth/mailer/mail.service.ts index 7ffe1101f2..702d2fae3e 100644 --- a/packages/backend/server/src/modules/auth/mailer/mail.service.ts +++ b/packages/backend/server/src/modules/auth/mailer/mail.service.ts @@ -72,15 +72,11 @@ export class MailService { invitationInfo.workspace.name }

Click button to join this workspace

`; - const subContent = - 'Currently, AFFiNE Cloud is in the early access stage. Only Early Access Sponsors can register and log in to AFFiNE Cloud. Please click here for more information.'; - const html = emailTemplate({ title: 'You are invited!', content, buttonContent: 'Accept & Join', buttonUrl, - subContent, }); return this.sendMail({ diff --git a/packages/backend/server/src/modules/auth/next-auth-options.ts b/packages/backend/server/src/modules/auth/next-auth-options.ts index 5e0317b508..fa40774a27 100644 --- a/packages/backend/server/src/modules/auth/next-auth-options.ts +++ b/packages/backend/server/src/modules/auth/next-auth-options.ts @@ -11,8 +11,8 @@ import Google from 'next-auth/providers/google'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; import { SessionService } from '../../session'; -import { NewFeaturesKind } from '../users/types'; -import { isStaff } from '../users/utils'; +import { FeatureType } from '../features'; +import { Quota_FreePlanV1 } from '../quota'; import { MailService } from './mailer'; import { decode, @@ -44,6 +44,17 @@ export const NextAuthOptionsProvider: FactoryProvider = { email: data.email, avatarUrl: '', emailVerified: data.emailVerified, + features: { + create: { + reason: 'created by email sign up', + activated: true, + feature: { + connect: { + feature_version: Quota_FreePlanV1, + }, + }, + }, + }, }; if (data.email && !data.name) { userData.name = data.email.split('@')[0]; @@ -223,18 +234,23 @@ export const NextAuthOptionsProvider: FactoryProvider = { } const email = profile?.email ?? user.email; if (email) { - if (isStaff(email)) { - return true; - } - return prisma.newFeaturesWaitingList - .findUnique({ + // FIXME: cannot inject FeatureManagementService here + // it will cause prisma.account to be undefined + // then prismaAdapter.getUserByAccount will throw error + if (email.endsWith('@toeverything.info')) return true; + return prisma.userFeatures + .count({ where: { - email, - type: NewFeaturesKind.EarlyAccess, + user: { + email, + }, + feature: { + feature: FeatureType.EarlyAccess, + }, + activated: true, }, }) - .then(user => !!user) - .catch(() => false); + .then(count => count > 0); } return false; }, @@ -242,6 +258,10 @@ export const NextAuthOptionsProvider: FactoryProvider = { return url; }, }; + + nextAuthOptions.pages = { + newUser: '/auth/onboarding', + }; return nextAuthOptions; }, inject: [Config, PrismaService, MailService, SessionService], diff --git a/packages/backend/server/src/modules/auth/resolver.ts b/packages/backend/server/src/modules/auth/resolver.ts index 680d0a37ec..697812520f 100644 --- a/packages/backend/server/src/modules/auth/resolver.ts +++ b/packages/backend/server/src/modules/auth/resolver.ts @@ -19,7 +19,7 @@ import { nanoid } from 'nanoid'; import { Config } from '../../config'; import { SessionService } from '../../session'; import { CloudThrottlerGuard, Throttle } from '../../throttler'; -import { UserType } from '../users/resolver'; +import { UserType } from '../users'; import { Auth, CurrentUser } from './guard'; import { AuthService } from './service'; diff --git a/packages/backend/server/src/modules/auth/service.ts b/packages/backend/server/src/modules/auth/service.ts index b6150a65d1..d544c41654 100644 --- a/packages/backend/server/src/modules/auth/service.ts +++ b/packages/backend/server/src/modules/auth/service.ts @@ -14,6 +14,7 @@ import { nanoid } from 'nanoid'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; import { verifyChallengeResponse } from '../../storage'; +import { Quota_FreePlanV1 } from '../quota'; import { MailService } from './mailer'; export type UserClaim = Pick< @@ -190,6 +191,17 @@ export class AuthService { name, email, password: hashedPassword, + features: { + create: { + reason: 'created by api sign up', + activated: true, + feature: { + connect: { + feature_version: Quota_FreePlanV1, + }, + }, + }, + }, }, }); } @@ -209,6 +221,17 @@ export class AuthService { data: { name: 'Unnamed', email, + features: { + create: { + reason: 'created by invite sign up', + activated: true, + feature: { + connect: { + feature_version: Quota_FreePlanV1, + }, + }, + }, + }, }, }); } @@ -258,6 +281,7 @@ export class AuthService { }, }); } + async changeEmail(id: string, newEmail: string): Promise { const user = await this.prisma.user.findUnique({ where: { diff --git a/packages/backend/server/src/modules/config.ts b/packages/backend/server/src/modules/config.ts index be9261f2a1..5d9b05e790 100644 --- a/packages/backend/server/src/modules/config.ts +++ b/packages/backend/server/src/modules/config.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { Field, ObjectType, Query } from '@nestjs/graphql'; -export const { SERVER_FLAVOR } = process.env; +import { SERVER_FLAVOR } from '../config'; @ObjectType() export class ServerConfigType { @@ -19,7 +19,7 @@ export class ServerConfigResolver { serverConfig(): ServerConfigType { return { version: AFFiNE.version, - flavor: SERVER_FLAVOR || 'allinone', + flavor: SERVER_FLAVOR, }; } } diff --git a/packages/backend/server/src/modules/doc/history.ts b/packages/backend/server/src/modules/doc/history.ts index fa52ecda97..ab6a5dcc7d 100644 --- a/packages/backend/server/src/modules/doc/history.ts +++ b/packages/backend/server/src/modules/doc/history.ts @@ -7,7 +7,7 @@ import { Config } from '../../config'; import { type EventPayload, OnEvent } from '../../event'; import { metrics } from '../../metrics'; import { PrismaService } from '../../prisma'; -import { SubscriptionStatus } from '../payment/service'; +import { QuotaService } from '../quota'; import { Permission } from '../workspaces/types'; import { isEmptyBuffer } from './manager'; @@ -16,7 +16,8 @@ export class DocHistoryManager { private readonly logger = new Logger(DocHistoryManager.name); constructor( private readonly config: Config, - private readonly db: PrismaService + private readonly db: PrismaService, + private readonly quota: QuotaService ) {} @OnEvent('workspace.deleted') @@ -222,9 +223,6 @@ export class DocHistoryManager { return history.timestamp; } - /** - * @todo(@darkskygit) refactor with [Usage Control] system - */ async getExpiredDateFromNow(workspaceId: string) { const permission = await this.db.workspaceUserPermission.findFirst({ select: { @@ -241,25 +239,8 @@ export class DocHistoryManager { throw new Error('Workspace owner not found'); } - const sub = await this.db.userSubscription.findFirst({ - select: { - id: true, - }, - where: { - userId: permission.userId, - status: SubscriptionStatus.Active, - }, - }); - - return new Date( - Date.now() + - 1000 * - 60 * - 60 * - 24 * - // 30 days for subscription user, 7 days for free user - (sub ? 30 : 7) - ); + const quota = await this.quota.getUserQuota(permission.userId); + return quota.feature.historyPeriodFromNow; } @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT /* everyday at 12am */) diff --git a/packages/backend/server/src/modules/doc/index.ts b/packages/backend/server/src/modules/doc/index.ts index bc4b719f5e..3630ddc943 100644 --- a/packages/backend/server/src/modules/doc/index.ts +++ b/packages/backend/server/src/modules/doc/index.ts @@ -1,38 +1,14 @@ -import { DynamicModule } from '@nestjs/common'; +import { Module } from '@nestjs/common'; +import { QuotaModule } from '../quota'; import { DocHistoryManager } from './history'; import { DocManager } from './manager'; -export class DocModule { - /** - * @param automation whether enable update merging automation logic - */ - private static defModule(automation = true): DynamicModule { - return { - module: DocModule, - providers: [ - { - provide: 'DOC_MANAGER_AUTOMATION', - useValue: automation, - }, - DocManager, - DocHistoryManager, - ], - exports: [DocManager, DocHistoryManager], - }; - } - - static forRoot() { - return this.defModule(); - } - - static forSync(): DynamicModule { - return this.defModule(false); - } - - static forFeature(): DynamicModule { - return this.defModule(false); - } -} +@Module({ + imports: [QuotaModule], + providers: [DocManager, DocHistoryManager], + exports: [DocManager, DocHistoryManager], +}) +export class DocModule {} export { DocHistoryManager, DocManager }; diff --git a/packages/backend/server/src/modules/doc/manager.ts b/packages/backend/server/src/modules/doc/manager.ts index e2306d083a..4ac2424f92 100644 --- a/packages/backend/server/src/modules/doc/manager.ts +++ b/packages/backend/server/src/modules/doc/manager.ts @@ -1,5 +1,4 @@ import { - Inject, Injectable, Logger, OnModuleDestroy, @@ -97,8 +96,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy { private busy = false; constructor( - @Inject('DOC_MANAGER_AUTOMATION') - private readonly automation: boolean, private readonly db: PrismaService, private readonly config: Config, private readonly cache: Cache, @@ -106,7 +103,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy { ) {} onModuleInit() { - if (this.automation) { + if (this.config.doc.manager.enableUpdateAutoMerging) { this.logger.log('Use Database'); this.setup(); } diff --git a/packages/backend/server/src/modules/features/feature.ts b/packages/backend/server/src/modules/features/feature.ts new file mode 100644 index 0000000000..f0bd2a763c --- /dev/null +++ b/packages/backend/server/src/modules/features/feature.ts @@ -0,0 +1,78 @@ +import { PrismaService } from '../../prisma'; +import { Feature, FeatureSchema, FeatureType } from './types'; + +class FeatureConfig { + readonly config: Feature; + + constructor(data: any) { + const config = FeatureSchema.safeParse(data); + if (config.success) { + this.config = config.data; + } else { + throw new Error(`Invalid quota config: ${config.error.message}`); + } + } + + /// feature name of quota + get name() { + return this.config.feature; + } +} + +export class EarlyAccessFeatureConfig extends FeatureConfig { + constructor(data: any) { + super(data); + + if (this.config.feature !== FeatureType.EarlyAccess) { + throw new Error('Invalid feature config: type is not EarlyAccess'); + } + } + + checkWhiteList(email: string) { + for (const domain in this.config.configs.whitelist) { + if (email.endsWith(domain)) { + return true; + } + } + return false; + } +} + +const FeatureConfigMap = { + [FeatureType.EarlyAccess]: EarlyAccessFeatureConfig, +}; + +const FeatureCache = new Map< + number, + InstanceType<(typeof FeatureConfigMap)[FeatureType]> +>(); + +export async function getFeature(prisma: PrismaService, featureId: number) { + const cachedQuota = FeatureCache.get(featureId); + + if (cachedQuota) { + return cachedQuota; + } + + const feature = await prisma.features.findFirst({ + where: { + id: featureId, + }, + }); + if (!feature) { + // this should unreachable + throw new Error(`Quota config ${featureId} not found`); + } + const ConfigClass = FeatureConfigMap[feature.feature as FeatureType]; + + if (!ConfigClass) { + throw new Error(`Feature config ${featureId} not found`); + } + + const config = new ConfigClass(feature); + // we always edit quota config as a new quota config + // so we can cache it by featureId + FeatureCache.set(featureId, config); + + return config; +} diff --git a/packages/backend/server/src/modules/features/index.ts b/packages/backend/server/src/modules/features/index.ts new file mode 100644 index 0000000000..ee8d61a283 --- /dev/null +++ b/packages/backend/server/src/modules/features/index.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; + +import { PrismaService } from '../../prisma'; +import { FeatureManagementService } from './management'; +import { FeatureService } from './service'; + +/** + * Feature module provider pre-user feature flag management. + * includes: + * - feature query/update/permit + * - feature statistics + */ +@Module({ + providers: [FeatureService, FeatureManagementService], + exports: [FeatureService, FeatureManagementService], +}) +export class FeatureModule {} + +export { type CommonFeature, commonFeatureSchema } from './types'; +export { FeatureKind, Features, FeatureType } from './types'; +export { FeatureManagementService, FeatureService, PrismaService }; diff --git a/packages/backend/server/src/modules/features/management.ts b/packages/backend/server/src/modules/features/management.ts new file mode 100644 index 0000000000..d7e274525c --- /dev/null +++ b/packages/backend/server/src/modules/features/management.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +import { Config } from '../../config'; +import { PrismaService } from '../../prisma'; +import { EarlyAccessFeatureConfig } from './feature'; +import { FeatureService } from './service'; +import { FeatureType } from './types'; + +enum NewFeaturesKind { + EarlyAccess, +} + +@Injectable() +export class FeatureManagementService implements OnModuleInit { + protected logger = new Logger(FeatureManagementService.name); + private earlyAccessFeature?: EarlyAccessFeatureConfig; + constructor( + private readonly feature: FeatureService, + private readonly prisma: PrismaService, + private readonly config: Config + ) {} + async onModuleInit() { + this.earlyAccessFeature = await this.feature.getFeature( + FeatureType.EarlyAccess + ); + } + + // ======== Admin ======== + + // todo(@darkskygit): replace this with abac + isStaff(email: string) { + return this.earlyAccessFeature?.checkWhiteList(email) ?? false; + } + + // ======== Early Access ======== + + async addEarlyAccess(userId: string) { + return this.feature.addUserFeature( + userId, + FeatureType.EarlyAccess, + 1, + 'Early access user' + ); + } + + async removeEarlyAccess(userId: string) { + return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess); + } + + async listEarlyAccess() { + return this.feature.listFeatureUsers(FeatureType.EarlyAccess); + } + + /// check early access by email + async canEarlyAccess(email: string) { + if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) { + const user = await this.prisma.user.findFirst({ + where: { + email, + }, + }); + if (user) { + const canEarlyAccess = await this.feature + .hasFeature(user.id, FeatureType.EarlyAccess) + .catch(() => false); + if (canEarlyAccess) { + return true; + } + + // TODO: Outdated, switch to feature gates + const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList + .findUnique({ + where: { email, type: NewFeaturesKind.EarlyAccess }, + }) + .then(x => !!x) + .catch(() => false); + if (oldCanEarlyAccess) { + this.logger.warn( + `User ${email} has early access in old table but not in new table` + ); + } + return oldCanEarlyAccess; + } + return false; + } else { + return true; + } + } +} diff --git a/packages/backend/server/src/modules/features/service.ts b/packages/backend/server/src/modules/features/service.ts new file mode 100644 index 0000000000..beed9dc4fb --- /dev/null +++ b/packages/backend/server/src/modules/features/service.ts @@ -0,0 +1,184 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../prisma'; +import { UserType } from '../users/types'; +import { getFeature } from './feature'; +import { FeatureKind, FeatureType } from './types'; + +@Injectable() +export class FeatureService { + constructor(private readonly prisma: PrismaService) {} + + async getFeaturesVersion() { + const features = await this.prisma.features.findMany({ + where: { + type: FeatureKind.Feature, + }, + select: { + feature: true, + version: true, + }, + }); + return features.reduce( + (acc, feature) => { + acc[feature.feature] = feature.version; + return acc; + }, + {} as Record + ); + } + + async getFeature(feature: FeatureType) { + const data = await this.prisma.features.findFirst({ + where: { + feature, + type: FeatureKind.Feature, + }, + select: { id: true }, + orderBy: { + version: 'desc', + }, + }); + if (data) { + return getFeature(this.prisma, data.id); + } + return undefined; + } + + async addUserFeature( + userId: string, + feature: FeatureType, + version: number, + reason: string, + expiredAt?: Date | string + ) { + return this.prisma.$transaction(async tx => { + const latestFlag = await tx.userFeatures.findFirst({ + where: { + userId, + feature: { + feature, + type: FeatureKind.Feature, + }, + activated: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + if (latestFlag) { + return latestFlag.id; + } else { + return tx.userFeatures + .create({ + data: { + reason, + expiredAt, + activated: true, + user: { + connect: { + id: userId, + }, + }, + feature: { + connect: { + feature_version: { + feature, + version, + }, + type: FeatureKind.Feature, + }, + }, + }, + }) + .then(r => r.id); + } + }); + } + + async removeUserFeature(userId: string, feature: FeatureType) { + return this.prisma.userFeatures + .updateMany({ + where: { + userId, + feature: { + feature, + type: FeatureKind.Feature, + }, + activated: true, + }, + data: { + activated: false, + }, + }) + .then(r => r.count); + } + + async getUserFeatures(userId: string) { + const features = await this.prisma.userFeatures.findMany({ + where: { + user: { id: userId }, + feature: { + type: FeatureKind.Feature, + }, + }, + select: { + activated: true, + reason: true, + createdAt: true, + expiredAt: true, + featureId: true, + }, + }); + + const configs = await Promise.all( + features.map(async feature => ({ + ...feature, + feature: await getFeature(this.prisma, feature.featureId), + })) + ); + + return configs.filter(feature => !!feature.feature); + } + + async listFeatureUsers(feature: FeatureType): Promise { + return this.prisma.userFeatures + .findMany({ + where: { + activated: true, + feature: { + feature: feature, + type: FeatureKind.Feature, + }, + }, + select: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + email: true, + emailVerified: true, + createdAt: true, + }, + }, + }, + }) + .then(users => users.map(user => user.user)); + } + + async hasFeature(userId: string, feature: FeatureType) { + return this.prisma.userFeatures + .count({ + where: { + userId, + activated: true, + feature: { + feature, + type: FeatureKind.Feature, + }, + }, + }) + .then(count => count > 0); + } +} diff --git a/packages/backend/server/src/modules/features/types.ts b/packages/backend/server/src/modules/features/types.ts new file mode 100644 index 0000000000..007463b07d --- /dev/null +++ b/packages/backend/server/src/modules/features/types.ts @@ -0,0 +1,65 @@ +import { URL } from 'node:url'; + +import { z } from 'zod'; + +/// ======== common schema ======== + +export enum FeatureKind { + Feature, + Quota, +} + +export const commonFeatureSchema = z.object({ + feature: z.string(), + type: z.nativeEnum(FeatureKind), + version: z.number(), + configs: z.unknown(), +}); + +export type CommonFeature = z.infer; + +/// ======== feature define ======== + +export enum FeatureType { + EarlyAccess = 'early_access', +} + +function checkHostname(host: string) { + try { + return new URL(`https://${host}`).hostname === host; + } catch (_) { + return false; + } +} + +const featureEarlyAccess = z.object({ + feature: z.literal(FeatureType.EarlyAccess), + configs: z.object({ + whitelist: z + .string() + .startsWith('@') + .refine(domain => checkHostname(domain.slice(1))) + .array(), + }), +}); + +export const Features: Feature[] = [ + { + feature: FeatureType.EarlyAccess, + type: FeatureKind.Feature, + version: 1, + configs: { + whitelist: ['@toeverything.info'], + }, + }, +]; + +/// ======== schema infer ======== + +export const FeatureSchema = commonFeatureSchema + .extend({ + type: z.literal(FeatureKind.Feature), + }) + .and(z.discriminatedUnion('feature', [featureEarlyAccess])); + +export type Feature = z.infer; diff --git a/packages/backend/server/src/modules/index.ts b/packages/backend/server/src/modules/index.ts index 422b276265..0b22eeb069 100644 --- a/packages/backend/server/src/modules/index.ts +++ b/packages/backend/server/src/modules/index.ts @@ -1,10 +1,12 @@ import { DynamicModule, Type } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; +import { SERVER_FLAVOR } from '../config'; import { GqlModule } from '../graphql.module'; -import { SERVER_FLAVOR, ServerConfigModule } from './config'; +import { ServerConfigModule } from './config'; import { DocModule } from './doc'; import { PaymentModule } from './payment'; +import { QuotaModule } from './quota'; import { SelfHostedModule } from './self-hosted'; import { SyncModule } from './sync'; import { UsersModule } from './users'; @@ -14,7 +16,7 @@ const BusinessModules: (Type | DynamicModule)[] = []; switch (SERVER_FLAVOR) { case 'sync': - BusinessModules.push(SyncModule, DocModule.forSync()); + BusinessModules.push(SyncModule, DocModule); break; case 'selfhosted': BusinessModules.push( @@ -25,7 +27,7 @@ switch (SERVER_FLAVOR) { WorkspaceModule, UsersModule, SyncModule, - DocModule.forRoot() + DocModule ); break; case 'graphql': @@ -35,8 +37,9 @@ switch (SERVER_FLAVOR) { GqlModule, WorkspaceModule, UsersModule, - DocModule.forRoot(), - PaymentModule + DocModule, + PaymentModule, + QuotaModule ); break; case 'allinone': @@ -47,8 +50,9 @@ switch (SERVER_FLAVOR) { GqlModule, WorkspaceModule, UsersModule, + QuotaModule, SyncModule, - DocModule.forRoot(), + DocModule, PaymentModule ); break; diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/modules/payment/index.ts index 30393504b5..86cea35d4a 100644 --- a/packages/backend/server/src/modules/payment/index.ts +++ b/packages/backend/server/src/modules/payment/index.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; -import { UsersModule } from '../users'; +import { FeatureModule } from '../features'; +import { QuotaModule } from '../quota'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; @@ -8,7 +9,7 @@ import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; @Module({ - imports: [UsersModule], + imports: [FeatureModule, QuotaModule], providers: [ ScheduleManager, StripeProvider, diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts index 7374f8e1d5..282d63acaa 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -1,6 +1,7 @@ import { HttpStatus } from '@nestjs/common'; import { Args, + Context, Field, Int, Mutation, @@ -254,8 +255,13 @@ export class UserSubscriptionResolver { constructor(private readonly db: PrismaService) {} @ResolveField(() => UserSubscriptionType, { nullable: true }) - async subscription(@CurrentUser() me: User, @Parent() user: User) { - if (me.id !== user.id) { + async subscription( + @Context() ctx: { isAdminQuery: boolean }, + @CurrentUser() me: User, + @Parent() user: User + ) { + // allow admin to query other user's subscription + if (!ctx.isAdminQuery && me.id !== user.id) { throw new GraphQLError( 'You are not allowed to access this subscription', { diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index c30ac03279..e858e12529 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -11,7 +11,8 @@ import Stripe from 'stripe'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; -import { UsersService } from '../users'; +import { FeatureManagementService } from '../features'; +import { QuotaService, QuotaType } from '../quota'; import { ScheduleManager } from './schedule'; const OnEvent = ( @@ -60,6 +61,11 @@ export enum SubscriptionStatus { Trialing = 'trialing', } +const SubscriptionActivated: Stripe.Subscription.Status[] = [ + SubscriptionStatus.Active, + SubscriptionStatus.Trialing, +]; + export enum InvoiceStatus { Draft = 'draft', Open = 'open', @@ -82,8 +88,9 @@ export class SubscriptionService { config: Config, private readonly stripe: Stripe, private readonly db: PrismaService, - private readonly user: UsersService, - private readonly scheduleManager: ScheduleManager + private readonly scheduleManager: ScheduleManager, + private readonly features: FeatureManagementService, + private readonly quota: QuotaService ) { this.paymentConfig = config.payment; @@ -471,6 +478,16 @@ export class SubscriptionService { } } + private getPlanQuota(plan: SubscriptionPlan) { + if (plan === SubscriptionPlan.Free) { + return QuotaType.FreePlanV1; + } else if (plan === SubscriptionPlan.Pro) { + return QuotaType.ProPlanV1; + } else { + throw new Error(`Unknown plan: ${plan}`); + } + } + private async saveSubscription( user: User, subscription: Stripe.Subscription, @@ -483,23 +500,28 @@ export class SubscriptionService { subscription = await this.stripe.subscriptions.retrieve(subscription.id); } - // get next bill date from upcoming invoice - // see https://stripe.com/docs/api/invoices/upcoming - let nextBillAt: Date | null = null; - if ( - (subscription.status === SubscriptionStatus.Active || - subscription.status === SubscriptionStatus.Trialing) && - !subscription.canceled_at - ) { - nextBillAt = new Date(subscription.current_period_end * 1000); - } - const price = subscription.items.data[0].price; if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); } const [plan, recurring] = decodeLookupKey(price.lookup_key); + const planActivated = SubscriptionActivated.includes(subscription.status); + + let nextBillAt: Date | null = null; + if (planActivated) { + // update user's quota if plan activated + await this.quota.switchUserQuota(user.id, this.getPlanQuota(plan)); + + // get next bill date from upcoming invoice + // see https://stripe.com/docs/api/invoices/upcoming + if (!subscription.canceled_at) { + nextBillAt = new Date(subscription.current_period_end * 1000); + } + } else { + // switch to free plan if subscription is canceled + await this.quota.switchUserQuota(user.id, QuotaType.FreePlanV1); + } const commonData = { start: new Date(subscription.current_period_start * 1000), @@ -658,7 +680,7 @@ export class SubscriptionService { user: User, couponType: CouponType ): Promise { - const earlyAccess = await this.user.isEarlyAccessUser(user.email); + const earlyAccess = await this.features.canEarlyAccess(user.email); if (earlyAccess) { try { const coupon = await this.stripe.coupons.retrieve(couponType); diff --git a/packages/backend/server/src/modules/quota/constant.ts b/packages/backend/server/src/modules/quota/constant.ts new file mode 100644 index 0000000000..e6fb0d25a0 --- /dev/null +++ b/packages/backend/server/src/modules/quota/constant.ts @@ -0,0 +1,5 @@ +export const OneKB = 1024; +export const OneMB = OneKB * OneKB; +export const OneGB = OneKB * OneMB; +export const OneDay = 1000 * 60 * 60 * 24; +export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; diff --git a/packages/backend/server/src/modules/quota/index.ts b/packages/backend/server/src/modules/quota/index.ts new file mode 100644 index 0000000000..59ac207e37 --- /dev/null +++ b/packages/backend/server/src/modules/quota/index.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; + +import { PermissionService } from '../workspaces/permission'; +import { QuotaService } from './service'; +import { QuotaManagementService } from './storage'; + +/** + * Quota module provider pre-user quota management. + * includes: + * - quota query/update/permit + * - quota statistics + */ +@Module({ + providers: [PermissionService, QuotaService, QuotaManagementService], + exports: [QuotaService, QuotaManagementService], +}) +export class QuotaModule {} + +export { QuotaManagementService, QuotaService }; +export { Quota_FreePlanV1, Quota_ProPlanV1, Quotas } from './schema'; +export { QuotaType } from './types'; diff --git a/packages/backend/server/src/modules/quota/quota.ts b/packages/backend/server/src/modules/quota/quota.ts new file mode 100644 index 0000000000..caf7c570eb --- /dev/null +++ b/packages/backend/server/src/modules/quota/quota.ts @@ -0,0 +1,81 @@ +import { PrismaService } from '../../prisma'; +import { formatDate, formatSize, Quota, QuotaSchema } from './types'; + +const QuotaCache = new Map(); + +export class QuotaConfig { + readonly config: Quota; + + static async get(prisma: PrismaService, featureId: number) { + const cachedQuota = QuotaCache.get(featureId); + + if (cachedQuota) { + return cachedQuota; + } + + const quota = await prisma.features.findFirst({ + where: { + id: featureId, + }, + }); + + if (!quota) { + throw new Error(`Quota config ${featureId} not found`); + } + + const config = new QuotaConfig(quota); + // we always edit quota config as a new quota config + // so we can cache it by featureId + QuotaCache.set(featureId, config); + + return config; + } + + private constructor(data: any) { + const config = QuotaSchema.safeParse(data); + if (config.success) { + this.config = config.data; + } else { + throw new Error( + `Invalid quota config: ${config.error.message}, ${JSON.stringify( + data + )})}` + ); + } + } + + /// feature name of quota + get name() { + return this.config.feature; + } + + get blobLimit() { + return this.config.configs.blobLimit; + } + + get storageQuota() { + return this.config.configs.storageQuota; + } + + get historyPeriod() { + return this.config.configs.historyPeriod; + } + + get historyPeriodFromNow() { + return new Date(Date.now() + this.historyPeriod); + } + + get memberLimit() { + return this.config.configs.memberLimit; + } + + get humanReadable() { + return { + name: this.config.configs.name, + blobLimit: formatSize(this.blobLimit), + storageQuota: formatSize(this.storageQuota), + historyPeriod: formatDate(this.historyPeriod), + memberLimit: this.memberLimit.toString(), + }; + } +} diff --git a/packages/backend/server/src/modules/quota/schema.ts b/packages/backend/server/src/modules/quota/schema.ts new file mode 100644 index 0000000000..a24f0db52a --- /dev/null +++ b/packages/backend/server/src/modules/quota/schema.ts @@ -0,0 +1,50 @@ +import { FeatureKind } from '../features'; +import { OneDay, OneGB, OneMB } from './constant'; +import { Quota, QuotaType } from './types'; + +export const Quotas: Quota[] = [ + { + feature: QuotaType.FreePlanV1, + type: FeatureKind.Quota, + version: 1, + configs: { + // quota name + name: 'Free', + // single blob limit 10MB + blobLimit: 10 * OneMB, + // total blob limit 10GB + storageQuota: 10 * OneGB, + // history period of validity 7 days + historyPeriod: 7 * OneDay, + // member limit 3 + memberLimit: 3, + }, + }, + { + feature: QuotaType.ProPlanV1, + type: FeatureKind.Quota, + version: 1, + configs: { + // quota name + name: 'Pro', + // single blob limit 100MB + blobLimit: 100 * OneMB, + // total blob limit 100GB + storageQuota: 100 * OneGB, + // history period of validity 30 days + historyPeriod: 30 * OneDay, + // member limit 10 + memberLimit: 10, + }, + }, +]; + +export const Quota_FreePlanV1 = { + feature: Quotas[0].feature, + version: Quotas[0].version, +}; + +export const Quota_ProPlanV1 = { + feature: Quotas[1].feature, + version: Quotas[1].version, +}; diff --git a/packages/backend/server/src/modules/quota/service.ts b/packages/backend/server/src/modules/quota/service.ts new file mode 100644 index 0000000000..cfafbbc3f0 --- /dev/null +++ b/packages/backend/server/src/modules/quota/service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../prisma'; +import { FeatureKind } from '../features'; +import { QuotaConfig } from './quota'; +import { QuotaType } from './types'; + +@Injectable() +export class QuotaService { + constructor(private readonly prisma: PrismaService) {} + + // get activated user quota + async getUserQuota(userId: string) { + const quota = await this.prisma.userFeatures.findFirst({ + where: { + user: { + id: userId, + }, + feature: { + type: FeatureKind.Quota, + }, + activated: true, + }, + select: { + reason: true, + createdAt: true, + expiredAt: true, + featureId: true, + }, + }); + + if (!quota) { + // this should unreachable + throw new Error(`User ${userId} has no quota`); + } + + const feature = await QuotaConfig.get(this.prisma, quota.featureId); + return { ...quota, feature }; + } + + // get user all quota records + async getUserQuotas(userId: string) { + const quotas = await this.prisma.userFeatures.findMany({ + where: { + user: { + id: userId, + }, + feature: { + type: FeatureKind.Quota, + }, + }, + select: { + activated: true, + reason: true, + createdAt: true, + expiredAt: true, + featureId: true, + }, + }); + const configs = await Promise.all( + quotas.map(async quota => { + try { + return { + ...quota, + feature: await QuotaConfig.get(this.prisma, quota.featureId), + }; + } catch (_) {} + return null as unknown as typeof quota & { + feature: QuotaConfig; + }; + }) + ); + + return configs.filter(quota => !!quota); + } + + // switch user to a new quota + // currently each user can only have one quota + async switchUserQuota( + userId: string, + quota: QuotaType, + reason?: string, + expiredAt?: Date + ) { + await this.prisma.$transaction(async tx => { + const latestPlanVersion = await tx.features.aggregate({ + where: { + feature: quota, + }, + _max: { + version: true, + }, + }); + + // we will deactivate all exists quota for this user + await tx.userFeatures.updateMany({ + where: { + id: undefined, + userId, + feature: { + type: FeatureKind.Quota, + }, + }, + data: { + activated: false, + }, + }); + + await tx.userFeatures.create({ + data: { + user: { + connect: { + id: userId, + }, + }, + feature: { + connect: { + feature_version: { + feature: quota, + version: latestPlanVersion._max.version || 1, + }, + type: FeatureKind.Quota, + }, + }, + reason: reason ?? 'switch quota', + activated: true, + expiredAt, + }, + }); + }); + } + + async hasQuota(userId: string, quota: QuotaType) { + return this.prisma.userFeatures + .count({ + where: { + userId, + feature: { + feature: quota, + type: FeatureKind.Quota, + }, + activated: true, + }, + }) + .then(count => count > 0); + } +} diff --git a/packages/backend/server/src/modules/quota/storage.ts b/packages/backend/server/src/modules/quota/storage.ts new file mode 100644 index 0000000000..fff2dd50df --- /dev/null +++ b/packages/backend/server/src/modules/quota/storage.ts @@ -0,0 +1,54 @@ +import type { Storage } from '@affine/storage'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; + +import { StorageProvide } from '../../storage'; +import { PermissionService } from '../workspaces/permission'; +import { QuotaService } from './service'; + +@Injectable() +export class QuotaManagementService { + constructor( + private readonly quota: QuotaService, + private readonly permissions: PermissionService, + @Inject(StorageProvide) private readonly storage: Storage + ) {} + + async getUserQuota(userId: string) { + const quota = await this.quota.getUserQuota(userId); + + return { + name: quota.feature.name, + reason: quota.reason, + createAt: quota.createdAt, + expiredAt: quota.expiredAt, + blobLimit: quota.feature.blobLimit, + storageQuota: quota.feature.storageQuota, + }; + } + + // TODO: lazy calc, need to be optimized with cache + async getUserUsage(userId: string) { + const workspaces = await this.permissions.getOwnedWorkspaces(userId); + return this.storage.blobsSize(workspaces); + } + + // get workspace's owner quota and total size of used + // quota was apply to owner's account + async getWorkspaceUsage(workspaceId: string) { + const { user: owner } = + await this.permissions.getWorkspaceOwner(workspaceId); + if (!owner) throw new NotFoundException('Workspace owner not found'); + const { storageQuota } = await this.getUserQuota(owner.id); + // get all workspaces size of owner used + const usageSize = await this.getUserUsage(owner.id); + + return { quota: storageQuota, size: usageSize }; + } + + async checkBlobQuota(workspaceId: string, size: number) { + const { quota, size: usageSize } = + await this.getWorkspaceUsage(workspaceId); + + return quota - (size + usageSize); + } +} diff --git a/packages/backend/server/src/modules/quota/types.ts b/packages/backend/server/src/modules/quota/types.ts new file mode 100644 index 0000000000..6097d6bc57 --- /dev/null +++ b/packages/backend/server/src/modules/quota/types.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { commonFeatureSchema, FeatureKind } from '../features'; +import { ByteUnit, OneDay, OneKB } from './constant'; + +/// ======== quota define ======== + +export enum QuotaType { + FreePlanV1 = 'free_plan_v1', + ProPlanV1 = 'pro_plan_v1', +} + +const quotaPlan = z.object({ + feature: z.enum([QuotaType.FreePlanV1, QuotaType.ProPlanV1]), + configs: z.object({ + name: z.string(), + blobLimit: z.number().positive().int(), + storageQuota: z.number().positive().int(), + historyPeriod: z.number().positive().int(), + memberLimit: z.number().positive().int(), + }), +}); + +/// ======== schema infer ======== + +export const QuotaSchema = commonFeatureSchema + .extend({ + type: z.literal(FeatureKind.Quota), + }) + .and(z.discriminatedUnion('feature', [quotaPlan])); + +export type Quota = z.infer; + +/// ======== utils ======== + +export function formatSize(bytes: number, decimals: number = 2): string { + if (bytes === 0) return '0 B'; + + const dm = decimals < 0 ? 0 : decimals; + + const i = Math.floor(Math.log(bytes) / Math.log(OneKB)); + + return ( + parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i] + ); +} + +export function formatDate(ms: number): string { + return `${(ms / OneDay).toFixed(0)} days`; +} diff --git a/packages/backend/server/src/modules/sync/events/events.gateway.ts b/packages/backend/server/src/modules/sync/events/events.gateway.ts index 4953241185..abc1a946d0 100644 --- a/packages/backend/server/src/modules/sync/events/events.gateway.ts +++ b/packages/backend/server/src/modules/sync/events/events.gateway.ts @@ -114,8 +114,8 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } @Auth() - @SubscribeMessage('client-handshake') - async handleClientHandShake( + @SubscribeMessage('client-handshake-sync') + async handleClientHandshakeSync( @CurrentUser() user: UserType, @MessageBody() workspaceId: string, @ConnectedSocket() client: Socket @@ -127,7 +127,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { ); if (canWrite) { - await client.join(workspaceId); + await client.join(`${workspaceId}:sync`); return { data: { clientId: client.id, @@ -140,13 +140,71 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } } - @SubscribeMessage('client-leave') - async handleClientLeave( + @Auth() + @SubscribeMessage('client-handshake-awareness') + async handleClientHandshakeAwareness( + @CurrentUser() user: UserType, + @MessageBody() workspaceId: string, + @ConnectedSocket() client: Socket + ): Promise> { + const canWrite = await this.permissions.tryCheckWorkspace( + workspaceId, + user.id, + Permission.Write + ); + + if (canWrite) { + await client.join(`${workspaceId}:awareness`); + return { + data: { + clientId: client.id, + }, + }; + } else { + return { + error: new AccessDeniedError(workspaceId), + }; + } + } + + /** + * @deprecated use `client-handshake-sync` and `client-handshake-awareness` instead + */ + @Auth() + @SubscribeMessage('client-handshake') + async handleClientHandShake( + @CurrentUser() user: UserType, + @MessageBody() + workspaceId: string, + @ConnectedSocket() client: Socket + ): Promise> { + const canWrite = await this.permissions.tryCheckWorkspace( + workspaceId, + user.id, + Permission.Write + ); + + if (canWrite) { + await client.join([`${workspaceId}:sync`, `${workspaceId}:awareness`]); + return { + data: { + clientId: client.id, + }, + }; + } else { + return { + error: new AccessDeniedError(workspaceId), + }; + } + } + + @SubscribeMessage('client-leave-sync') + async handleLeaveSync( @MessageBody() workspaceId: string, @ConnectedSocket() client: Socket ): Promise { - if (client.rooms.has(workspaceId)) { - await client.leave(workspaceId); + if (client.rooms.has(`${workspaceId}:sync`)) { + await client.leave(`${workspaceId}:sync`); return {}; } else { return { @@ -155,6 +213,38 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } } + @SubscribeMessage('client-leave-awareness') + async handleLeaveAwareness( + @MessageBody() workspaceId: string, + @ConnectedSocket() client: Socket + ): Promise { + if (client.rooms.has(`${workspaceId}:awareness`)) { + await client.leave(`${workspaceId}:awareness`); + return {}; + } else { + return { + error: new NotInWorkspaceError(workspaceId), + }; + } + } + + /** + * @deprecated use `client-leave-sync` and `client-leave-awareness` instead + */ + @SubscribeMessage('client-leave') + async handleClientLeave( + @MessageBody() workspaceId: string, + @ConnectedSocket() client: Socket + ): Promise { + if (client.rooms.has(`${workspaceId}:sync`)) { + await client.leave(`${workspaceId}:sync`); + } + if (client.rooms.has(`${workspaceId}:awareness`)) { + await client.leave(`${workspaceId}:awareness`); + } + return {}; + } + /** * This is the old version of the `client-update` event without any data protocol. * It only exists for backwards compatibility to adapt older clients. @@ -175,7 +265,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { }, @ConnectedSocket() client: Socket ) { - if (!client.rooms.has(workspaceId)) { + if (!client.rooms.has(`${workspaceId}:sync`)) { this.logger.verbose( `Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first` ); @@ -185,12 +275,12 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { const docId = new DocID(guid, workspaceId); client - .to(docId.workspace) + .to(`${docId.workspace}:sync`) .emit('server-update', { workspaceId, guid, update }); // broadcast to all clients with newer version that only listen to `server-updates` client - .to(docId.workspace) + .to(`${docId.workspace}:sync`) .emit('server-updates', { workspaceId, guid, updates: [update] }); const buf = Buffer.from(update, 'base64'); @@ -219,7 +309,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { stateVector?: string; } ): Promise<{ missing: string; state?: string } | false> { - if (!client.rooms.has(workspaceId)) { + if (!client.rooms.has(`${workspaceId}:sync`)) { const canRead = await this.permissions.tryCheckWorkspace( workspaceId, user.id @@ -264,7 +354,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { }, @ConnectedSocket() client: Socket ): Promise> { - if (!client.rooms.has(workspaceId)) { + if (!client.rooms.has(`${workspaceId}:sync`)) { return { error: new NotInWorkspaceError(workspaceId), }; @@ -272,7 +362,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { const docId = new DocID(guid, workspaceId); client - .to(docId.workspace) + .to(`${docId.workspace}:sync`) .emit('server-updates', { workspaceId, guid, updates }); const buffers = updates.map(update => Buffer.from(update, 'base64')); @@ -301,7 +391,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { stateVector?: string; } ): Promise> { - if (!client.rooms.has(workspaceId)) { + if (!client.rooms.has(`${workspaceId}:sync`)) { const canRead = await this.permissions.tryCheckWorkspace( workspaceId, user.id @@ -343,8 +433,8 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() workspaceId: string, @ConnectedSocket() client: Socket ): Promise> { - if (client.rooms.has(workspaceId)) { - client.to(workspaceId).emit('new-client-awareness-init'); + if (client.rooms.has(`${workspaceId}:awareness`)) { + client.to(`${workspaceId}:awareness`).emit('new-client-awareness-init'); return { data: { clientId: client.id, @@ -362,9 +452,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() message: { workspaceId: string; awarenessUpdate: string }, @ConnectedSocket() client: Socket ): Promise { - if (client.rooms.has(message.workspaceId)) { + if (client.rooms.has(`${message.workspaceId}:awareness`)) { client - .to(message.workspaceId) + .to(`${message.workspaceId}:awareness`) .emit('server-awareness-broadcast', message); return {}; } else { diff --git a/packages/backend/server/src/modules/sync/events/events.module.ts b/packages/backend/server/src/modules/sync/events/events.module.ts index cf2fbb9ff3..82681955ce 100644 --- a/packages/backend/server/src/modules/sync/events/events.module.ts +++ b/packages/backend/server/src/modules/sync/events/events.module.ts @@ -5,7 +5,7 @@ import { PermissionService } from '../../workspaces/permission'; import { EventsGateway } from './events.gateway'; @Module({ - imports: [DocModule.forFeature()], + imports: [DocModule], providers: [EventsGateway, PermissionService], }) export class EventsModule {} diff --git a/packages/backend/server/src/modules/users/gates.ts b/packages/backend/server/src/modules/users/gates.ts deleted file mode 100644 index 191e00752e..0000000000 --- a/packages/backend/server/src/modules/users/gates.ts +++ /dev/null @@ -1,42 +0,0 @@ -type FeatureEarlyAccessPreview = { - whitelist: RegExp[]; -}; - -type FeatureStorageLimit = { - storageQuota: number; -}; - -type UserFeatureGate = { - earlyAccessPreview: FeatureEarlyAccessPreview; - freeUser: FeatureStorageLimit; - proUser: FeatureStorageLimit; -}; - -const UserLevel = { - freeUser: { - storageQuota: 10 * 1024 * 1024 * 1024, - }, - proUser: { - storageQuota: 100 * 1024 * 1024 * 1024, - }, -} satisfies Pick; - -export function getStorageQuota(features: string[]) { - for (const feature of features) { - if (feature in UserLevel) { - return UserLevel[feature as keyof typeof UserLevel].storageQuota; - } - } - return null; -} - -const UserType = { - earlyAccessPreview: { - whitelist: [/@toeverything\.info$/], - }, -} satisfies Pick; - -export const FeatureGates = { - ...UserType, - ...UserLevel, -} satisfies UserFeatureGate; diff --git a/packages/backend/server/src/modules/users/index.ts b/packages/backend/server/src/modules/users/index.ts index 308a1a9a13..e48f44e7b7 100644 --- a/packages/backend/server/src/modules/users/index.ts +++ b/packages/backend/server/src/modules/users/index.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; +import { FeatureModule } from '../features'; +import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; import { UserResolver } from './resolver'; import { UsersService } from './users'; @Module({ - imports: [StorageModule], + imports: [StorageModule, FeatureModule, QuotaModule], providers: [UserResolver, UsersService], exports: [UsersService], }) export class UsersModule {} -export { UserType } from './resolver'; +export { UserType } from './types'; export { UsersService } from './users'; diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/modules/users/resolver.ts index 806fa41cab..d816c111d2 100644 --- a/packages/backend/server/src/modules/users/resolver.ts +++ b/packages/backend/server/src/modules/users/resolver.ts @@ -6,13 +6,10 @@ import { } from '@nestjs/common'; import { Args, - Field, - ID, + Context, Int, Mutation, - ObjectType, Query, - registerEnumType, ResolveField, Resolver, } from '@nestjs/graphql'; @@ -24,60 +21,12 @@ import { PrismaService } from '../../prisma/service'; import { CloudThrottlerGuard, Throttle } from '../../throttler'; import type { FileUpload } from '../../types'; import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; +import { AuthService } from '../auth/service'; +import { FeatureManagementService } from '../features'; +import { QuotaService } from '../quota'; import { StorageService } from '../storage/storage.service'; -import { NewFeaturesKind } from './types'; +import { DeleteAccount, RemoveAvatar, UserQuotaType, UserType } from './types'; import { UsersService } from './users'; -import { isStaff } from './utils'; - -registerEnumType(NewFeaturesKind, { - name: 'NewFeaturesKind', -}); - -@ObjectType() -export class UserType implements Partial { - @Field(() => ID) - id!: string; - - @Field({ description: 'User name' }) - name!: string; - - @Field({ description: 'User email' }) - email!: string; - - @Field(() => String, { description: 'User avatar url', nullable: true }) - avatarUrl: string | null = null; - - @Field(() => Date, { description: 'User email verified', nullable: true }) - emailVerified: Date | null = null; - - @Field({ description: 'User created date', nullable: true }) - createdAt!: Date; - - @Field(() => Boolean, { - description: 'User password has been set', - nullable: true, - }) - hasPassword?: boolean; -} - -@ObjectType() -export class DeleteAccount { - @Field() - success!: boolean; -} -@ObjectType() -export class RemoveAvatar { - @Field() - success!: boolean; -} - -@ObjectType() -export class AddToNewFeaturesWaitingList { - @Field() - email!: string; - @Field(() => NewFeaturesKind, { description: 'New features kind' }) - type!: NewFeaturesKind; -} /** * User resolver @@ -88,9 +37,12 @@ export class AddToNewFeaturesWaitingList { @Resolver(() => UserType) export class UserResolver { constructor( + private readonly auth: AuthService, private readonly prisma: PrismaService, private readonly storage: StorageService, - private readonly users: UsersService + private readonly users: UsersService, + private readonly feature: FeatureManagementService, + private readonly quota: QuotaService ) {} @Throttle({ @@ -138,7 +90,7 @@ export class UserResolver { }) @Public() async user(@Args('email') email: string) { - if (!(await this.users.canEarlyAccess(email))) { + if (!(await this.feature.canEarlyAccess(email))) { return new GraphQLError( `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`, { @@ -158,6 +110,14 @@ export class UserResolver { return user; } + @Throttle({ default: { limit: 10, ttl: 60 } }) + @ResolveField(() => UserQuotaType, { name: 'quota', nullable: true }) + async getQuota(@CurrentUser() me: User) { + const quota = await this.quota.getUserQuota(me.id); + + return quota.feature; + } + @Throttle({ default: { limit: 10, ttl: 60 } }) @ResolveField(() => Int, { name: 'invoiceCount', @@ -233,27 +193,60 @@ export class UserResolver { ttl: 60, }, }) - @Mutation(() => AddToNewFeaturesWaitingList) - async addToNewFeaturesWaitingList( - @CurrentUser() user: UserType, - @Args('type', { - type: () => NewFeaturesKind, - }) - type: NewFeaturesKind, + @Mutation(() => Int) + async addToEarlyAccess( + @CurrentUser() currentUser: UserType, @Args('email') email: string - ): Promise { - if (!isStaff(user.email)) { + ): Promise { + if (!this.feature.isStaff(currentUser.email)) { throw new ForbiddenException('You are not allowed to do this'); } - await this.prisma.newFeaturesWaitingList.create({ - data: { - email, - type, - }, - }); - return { - email, - type, - }; + const user = await this.users.findUserByEmail(email); + if (user) { + return this.feature.addEarlyAccess(user.id); + } else { + const user = await this.auth.createAnonymousUser(email); + return this.feature.addEarlyAccess(user.id); + } + } + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => Int) + async removeEarlyAccess( + @CurrentUser() currentUser: UserType, + @Args('email') email: string + ): Promise { + if (!this.feature.isStaff(currentUser.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + const user = await this.users.findUserByEmail(email); + if (!user) { + throw new BadRequestException(`User ${email} not found`); + } + return this.feature.removeEarlyAccess(user.id); + } + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Query(() => [UserType]) + async earlyAccessUsers( + @Context() ctx: { isAdminQuery: boolean }, + @CurrentUser() user: UserType + ): Promise { + if (!this.feature.isStaff(user.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + // allow query other user's subscription + ctx.isAdminQuery = true; + return this.feature.listEarlyAccess(); } } diff --git a/packages/backend/server/src/modules/users/types.ts b/packages/backend/server/src/modules/users/types.ts index 565ff3827d..8e95409f09 100644 --- a/packages/backend/server/src/modules/users/types.ts +++ b/packages/backend/server/src/modules/users/types.ts @@ -1,3 +1,79 @@ -export enum NewFeaturesKind { - EarlyAccess, +import { Field, Float, ID, ObjectType } from '@nestjs/graphql'; +import type { User } from '@prisma/client'; + +@ObjectType('UserQuotaHumanReadable') +export class UserQuotaHumanReadableType { + @Field({ name: 'name' }) + name!: string; + + @Field({ name: 'blobLimit' }) + blobLimit!: string; + + @Field({ name: 'storageQuota' }) + storageQuota!: string; + + @Field({ name: 'historyPeriod' }) + historyPeriod!: string; + + @Field({ name: 'memberLimit' }) + memberLimit!: string; +} + +@ObjectType('UserQuota') +export class UserQuotaType { + @Field({ name: 'name' }) + name!: string; + + @Field(() => Float, { name: 'blobLimit' }) + blobLimit!: number; + + @Field(() => Float, { name: 'storageQuota' }) + storageQuota!: number; + + @Field(() => Float, { name: 'historyPeriod' }) + historyPeriod!: number; + + @Field({ name: 'memberLimit' }) + memberLimit!: number; + + @Field({ name: 'humanReadable' }) + humanReadable!: UserQuotaHumanReadableType; +} + +@ObjectType() +export class UserType implements Partial { + @Field(() => ID) + id!: string; + + @Field({ description: 'User name' }) + name!: string; + + @Field({ description: 'User email' }) + email!: string; + + @Field(() => String, { description: 'User avatar url', nullable: true }) + avatarUrl: string | null = null; + + @Field(() => Date, { description: 'User email verified', nullable: true }) + emailVerified: Date | null = null; + + @Field({ description: 'User created date', nullable: true }) + createdAt!: Date; + + @Field(() => Boolean, { + description: 'User password has been set', + nullable: true, + }) + hasPassword?: boolean; +} + +@ObjectType() +export class DeleteAccount { + @Field() + success!: boolean; +} +@ObjectType() +export class RemoveAvatar { + @Field() + success!: boolean; } diff --git a/packages/backend/server/src/modules/users/users.ts b/packages/backend/server/src/modules/users/users.ts index 1d56c936b2..6215ea4433 100644 --- a/packages/backend/server/src/modules/users/users.ts +++ b/packages/backend/server/src/modules/users/users.ts @@ -1,51 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { Config } from '../../config'; import { PrismaService } from '../../prisma'; -import { getStorageQuota } from './gates'; -import { NewFeaturesKind } from './types'; -import { isStaff } from './utils'; @Injectable() export class UsersService { - constructor( - private readonly prisma: PrismaService, - private readonly config: Config - ) {} - - async canEarlyAccess(email: string) { - if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) { - return this.isEarlyAccessUser(email); - } else { - return true; - } - } - - async isEarlyAccessUser(email: string) { - return this.prisma.newFeaturesWaitingList - .count({ - where: { email, type: NewFeaturesKind.EarlyAccess }, - }) - .then(count => count > 0) - .catch(() => false); - } - - async getStorageQuotaById(id: string) { - const features = await this.prisma.user - .findUnique({ - where: { id }, - select: { - features: { - select: { - feature: true, - }, - }, - }, - }) - .then(user => user?.features.map(f => f.feature) ?? []); - - return getStorageQuota(features) || this.config.objectStorage.quota; - } + constructor(private readonly prisma: PrismaService) {} async findUserByEmail(email: string) { return this.prisma.user diff --git a/packages/backend/server/src/modules/users/utils.ts b/packages/backend/server/src/modules/users/utils.ts deleted file mode 100644 index a10cedbcaa..0000000000 --- a/packages/backend/server/src/modules/users/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isStaff(email: string) { - return email.endsWith('@toeverything.info'); -} diff --git a/packages/backend/server/src/modules/workspaces/index.ts b/packages/backend/server/src/modules/workspaces/index.ts index 58a9f377af..50259cd5d2 100644 --- a/packages/backend/server/src/modules/workspaces/index.ts +++ b/packages/backend/server/src/modules/workspaces/index.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { DocModule } from '../doc'; +import { QuotaModule } from '../quota'; import { UsersService } from '../users'; import { WorkspacesController } from './controller'; import { DocHistoryResolver } from './history.resolver'; @@ -8,7 +9,7 @@ import { PermissionService } from './permission'; import { PagePermissionResolver, WorkspaceResolver } from './resolver'; @Module({ - imports: [DocModule.forFeature()], + imports: [DocModule, QuotaModule], controllers: [WorkspacesController], providers: [ WorkspaceResolver, diff --git a/packages/backend/server/src/modules/workspaces/permission.ts b/packages/backend/server/src/modules/workspaces/permission.ts index d735625998..524ab37e39 100644 --- a/packages/backend/server/src/modules/workspaces/permission.ts +++ b/packages/backend/server/src/modules/workspaces/permission.ts @@ -26,6 +26,18 @@ export class PermissionService { return data?.type as Permission; } + async getOwnedWorkspaces(userId: string) { + return this.prisma.workspaceUserPermission + .findMany({ + where: { + userId, + accepted: true, + type: Permission.Owner, + }, + }) + .then(data => data.map(({ workspaceId }) => workspaceId)); + } + async getWorkspaceOwner(workspaceId: string) { return this.prisma.workspaceUserPermission.findFirstOrThrow({ where: { diff --git a/packages/backend/server/src/modules/workspaces/resolver.ts b/packages/backend/server/src/modules/workspaces/resolver.ts index bbb772d76f..1880ea935a 100644 --- a/packages/backend/server/src/modules/workspaces/resolver.ts +++ b/packages/backend/server/src/modules/workspaces/resolver.ts @@ -33,6 +33,7 @@ import type { import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { applyUpdate, Doc } from 'yjs'; +import { MakeCache, PreventCache } from '../../cache'; import { EventEmitter } from '../../event'; import { PrismaService } from '../../prisma'; import { StorageProvide } from '../../storage'; @@ -42,8 +43,8 @@ import { DocID } from '../../utils/doc'; import { Auth, CurrentUser, Public } from '../auth'; import { MailService } from '../auth/mailer'; import { AuthService } from '../auth/service'; -import { UsersService } from '../users'; -import { UserType } from '../users/resolver'; +import { QuotaManagementService } from '../quota'; +import { UsersService, UserType } from '../users'; import { PermissionService, PublicPageMode } from './permission'; import { Permission } from './types'; import { defaultWorkspaceAvatar } from './utils'; @@ -148,6 +149,7 @@ export class WorkspaceResolver { private readonly permissions: PermissionService, private readonly users: UsersService, private readonly event: EventEmitter, + private readonly quota: QuotaManagementService, @Inject(StorageProvide) private readonly storage: Storage ) {} @@ -233,6 +235,14 @@ export class WorkspaceResolver { })); } + @ResolveField(() => Int, { + description: 'Blobs size of workspace', + complexity: 2, + }) + async blobsSize(@Parent() workspace: WorkspaceType) { + return this.storage.blobsSize([workspace.id]); + } + @Query(() => Boolean, { description: 'Get is owner of workspace', complexity: 2, @@ -647,6 +657,7 @@ export class WorkspaceResolver { @Query(() => [String], { description: 'List blobs of workspace', }) + @MakeCache(['blobs'], ['workspaceId']) async listBlobs( @CurrentUser() user: UserType, @Args('workspaceId') workspaceId: string @@ -656,36 +667,9 @@ export class WorkspaceResolver { return this.storage.listBlobs(workspaceId); } - @Query(() => WorkspaceBlobSizes) - async collectBlobSizes( - @CurrentUser() user: UserType, - @Args('workspaceId') workspaceId: string - ) { - await this.permissions.checkWorkspace(workspaceId, user.id); - - return this.storage.blobsSize([workspaceId]).then(size => ({ size })); - } - @Query(() => WorkspaceBlobSizes) async collectAllBlobSizes(@CurrentUser() user: UserType) { - const workspaces = await this.prisma.workspaceUserPermission - .findMany({ - where: { - userId: user.id, - accepted: true, - type: Permission.Owner, - }, - select: { - workspace: { - select: { - id: true, - }, - }, - }, - }) - .then(data => data.map(({ workspace }) => workspace.id)); - - const size = await this.storage.blobsSize(workspaces); + const size = await this.quota.getUserUsage(user.id); return { size }; } @@ -693,7 +677,7 @@ export class WorkspaceResolver { async checkBlobSize( @CurrentUser() user: UserType, @Args('workspaceId') workspaceId: string, - @Args('size', { type: () => Float }) size: number + @Args('size', { type: () => Float }) blobSize: number ) { const canWrite = await this.permissions.tryCheckWorkspace( workspaceId, @@ -701,18 +685,14 @@ export class WorkspaceResolver { Permission.Write ); if (canWrite) { - const { user } = await this.permissions.getWorkspaceOwner(workspaceId); - if (user) { - const quota = await this.users.getStorageQuotaById(user.id); - const { size: currentSize } = await this.collectAllBlobSizes(user); - - return { size: quota - (size + currentSize) }; - } + const size = await this.quota.checkBlobQuota(workspaceId, blobSize); + return { size }; } return false; } @Mutation(() => String) + @PreventCache(['blobs'], ['workspaceId']) async setBlob( @CurrentUser() user: UserType, @Args('workspaceId') workspaceId: string, @@ -725,14 +705,12 @@ export class WorkspaceResolver { Permission.Write ); - // quota was apply to owner's account - const { user: owner } = - await this.permissions.getWorkspaceOwner(workspaceId); - if (!owner) return new NotFoundException('Workspace owner not found'); - const quota = await this.users.getStorageQuotaById(owner.id); - const { size } = await this.collectAllBlobSizes(owner); + const { quota, size } = await this.quota.getWorkspaceUsage(workspaceId); const checkExceeded = (recvSize: number) => { + if (!quota) { + throw new ForbiddenException('cannot find user quota'); + } if (size + recvSize > quota) { this.logger.log( `storage size limit exceeded: ${size + recvSize} > ${quota}` @@ -774,6 +752,7 @@ export class WorkspaceResolver { } @Mutation(() => Boolean) + @PreventCache(['blobs'], ['workspaceId']) async deleteBlob( @CurrentUser() user: UserType, @Args('workspaceId') workspaceId: string, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index c6ee591b28..6707d700df 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -10,6 +10,23 @@ type ServerConfigType { flavor: String! } +type UserQuotaHumanReadable { + name: String! + blobLimit: String! + storageQuota: String! + historyPeriod: String! + memberLimit: String! +} + +type UserQuota { + name: String! + blobLimit: Float! + storageQuota: Float! + historyPeriod: Float! + memberLimit: Int! + humanReadable: UserQuotaHumanReadable! +} + type UserType { id: ID! @@ -31,6 +48,7 @@ type UserType { """User password has been set""" hasPassword: Boolean token: TokenType! + quota: UserQuota """Get user invoice count""" invoiceCount: Int! @@ -51,17 +69,6 @@ type RemoveAvatar { success: Boolean! } -type AddToNewFeaturesWaitingList { - email: String! - - """New features kind""" - type: NewFeaturesKind! -} - -enum NewFeaturesKind { - EarlyAccess -} - type TokenType { token: String! refresh: String! @@ -196,6 +203,9 @@ type WorkspaceType { """Owner of workspace""" owner: UserType! + """Blobs size of workspace""" + blobsSize: Int! + """Shared pages of workspace""" sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") @@ -269,7 +279,6 @@ type Query { """List blobs of workspace""" listBlobs(workspaceId: String!): [String!]! - collectBlobSizes(workspaceId: String!): WorkspaceBlobSizes! collectAllBlobSizes: WorkspaceBlobSizes! checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes! @@ -278,6 +287,7 @@ type Query { """Get user by email""" user(email: String!): UserType + earlyAccessUsers: [UserType!]! prices: [SubscriptionPrice!]! } @@ -315,7 +325,8 @@ type Mutation { """Remove user avatar""" removeAvatar: RemoveAvatar! deleteAccount: DeleteAccount! - addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList! + addToEarlyAccess(email: String!): Int! + removeEarlyAccess(email: String!): Int! """Create a subscription checkout link of stripe""" checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String! diff --git a/packages/backend/server/src/throttler.ts b/packages/backend/server/src/throttler.ts index 439b054985..76cf0e5720 100644 --- a/packages/backend/server/src/throttler.ts +++ b/packages/backend/server/src/throttler.ts @@ -9,14 +9,13 @@ import { import Redis from 'ioredis'; import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; -import { Config, ConfigModule } from './config'; +import { Config } from './config'; import { getRequestResponseFromContext } from './utils/nestjs'; @Global() @Module({ imports: [ ThrottlerModule.forRootAsync({ - imports: [ConfigModule], inject: [Config], useFactory: (config: Config): ThrottlerModuleOptions => { const options: ThrottlerModuleOptions = { diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index fb216ef173..1578fe5f87 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -12,6 +12,7 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import request from 'supertest'; import { AppModule } from '../src/app'; +import { FeatureManagementService } from '../src/modules/features'; import { PrismaService } from '../src/prisma/service'; const gql = '/graphql'; @@ -45,6 +46,13 @@ class FakePrisma { }, }; } + get newFeaturesWaitingList() { + return { + async findUnique() { + return null; + }, + }; + } } test.beforeEach(async t => { @@ -53,6 +61,8 @@ test.beforeEach(async t => { }) .overrideProvider(PrismaService) .useClass(FakePrisma) + .overrideProvider(FeatureManagementService) + .useValue({ canEarlyAccess: () => true }) .compile(); t.context.app = module.createNestApplication({ cors: true, diff --git a/packages/backend/server/tests/auth.e2e.ts b/packages/backend/server/tests/auth.e2e.ts index 66bc18fc02..a159a8ae6b 100644 --- a/packages/backend/server/tests/auth.e2e.ts +++ b/packages/backend/server/tests/auth.e2e.ts @@ -9,11 +9,13 @@ import ava, { type TestFn } from 'ava'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule } from '../src/app'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; import { MailService } from '../src/modules/auth/mailer'; import { AuthService } from '../src/modules/auth/service'; import { changeEmail, createWorkspace, + initFeatureConfigs, sendChangeEmail, sendVerifyChangeEmail, signUp, @@ -37,6 +39,7 @@ test.beforeEach(async t => { await client.$disconnect(); const module = await Test.createTestingModule({ imports: [AppModule], + providers: [RevertCommand, RunCommand], }).compile(); const app = module.createNestApplication(); app.use( @@ -52,6 +55,9 @@ test.beforeEach(async t => { t.context.app = app; t.context.auth = auth; t.context.mail = mail; + + // init features + await initFeatureConfigs(module); }); test.afterEach(async t => { diff --git a/packages/backend/server/tests/auth.spec.ts b/packages/backend/server/tests/auth.spec.ts index c60f46d8af..b3edd260f2 100644 --- a/packages/backend/server/tests/auth.spec.ts +++ b/packages/backend/server/tests/auth.spec.ts @@ -4,6 +4,7 @@ import { PrismaClient } from '@prisma/client'; import test from 'ava'; import { ConfigModule } from '../src/config'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; import { GqlModule } from '../src/graphql.module'; import { AuthModule } from '../src/modules/auth'; import { AuthResolver } from '../src/modules/auth/resolver'; @@ -11,6 +12,7 @@ import { AuthService } from '../src/modules/auth/service'; import { PrismaModule } from '../src/prisma'; import { mintChallengeResponse, verifyChallengeResponse } from '../src/storage'; import { RateLimiterModule } from '../src/throttler'; +import { initFeatureConfigs } from './utils'; let authService: AuthService; let authResolver: AuthResolver; @@ -40,10 +42,15 @@ test.beforeEach(async () => { GqlModule, AuthModule, RateLimiterModule, + RevertCommand, + RunCommand, ], }).compile(); authService = module.get(AuthService); authResolver = module.get(AuthResolver); + + // init features + await initFeatureConfigs(module); }); test.afterEach.always(async () => { diff --git a/packages/backend/server/tests/doc.spec.ts b/packages/backend/server/tests/doc.spec.ts index 76ff2a1ca1..f2a8e5ac7e 100644 --- a/packages/backend/server/tests/doc.spec.ts +++ b/packages/backend/server/tests/doc.spec.ts @@ -14,10 +14,16 @@ import { import { CacheModule } from '../src/cache'; import { Config, ConfigModule } from '../src/config'; +import { + collectMigrations, + RevertCommand, + RunCommand, +} from '../src/data/commands/run'; import { EventModule } from '../src/event'; import { DocManager, DocModule } from '../src/modules/doc'; +import { QuotaModule } from '../src/modules/quota'; import { PrismaModule, PrismaService } from '../src/prisma'; -import { flushDB } from './utils'; +import { FakeStorageModule, flushDB } from './utils'; const createModule = () => { return Test.createTestingModule({ @@ -25,8 +31,12 @@ const createModule = () => { PrismaModule, CacheModule, EventModule, + QuotaModule, + FakeStorageModule.forRoot(), ConfigModule.forRoot(), - DocModule.forRoot(), + DocModule, + RevertCommand, + RunCommand, ], }).compile(); }; @@ -45,6 +55,13 @@ test.beforeEach(async () => { app = m.createNestApplication(); app.enableShutdownHooks(); await app.init(); + + // init features + const run = m.get(RunCommand); + const revert = m.get(RevertCommand); + const migrations = await collectMigrations(); + await Promise.allSettled(migrations.map(m => revert.run([m.name]))); + await run.run(); }); test.afterEach.always(async () => { diff --git a/packages/backend/server/tests/exception-logger.e2e.ts b/packages/backend/server/tests/exception-logger.e2e.ts index 12f00ee9eb..fccd7117b0 100644 --- a/packages/backend/server/tests/exception-logger.e2e.ts +++ b/packages/backend/server/tests/exception-logger.e2e.ts @@ -6,6 +6,7 @@ import request from 'supertest'; import { AppModule } from '../src/app'; import { ExceptionLogger } from '../src/middleware/exception-logger'; +import { FeatureManagementService } from '../src/modules/features'; import { PrismaService } from '../src/prisma'; const gql = '/graphql'; @@ -38,6 +39,8 @@ test.beforeEach(async () => { }) .overrideProvider(PrismaService) .useClass(FakePrisma) + .overrideProvider(FeatureManagementService) + .useValue({}) .compile(); app = module.createNestApplication({ cors: true, diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts new file mode 100644 index 0000000000..ecc73db85c --- /dev/null +++ b/packages/backend/server/tests/feature.spec.ts @@ -0,0 +1,143 @@ +/// + +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { type TestFn } from 'ava'; + +import { ConfigModule } from '../src/config'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; +import { AuthModule } from '../src/modules/auth'; +import { AuthService } from '../src/modules/auth/service'; +import { + FeatureManagementService, + FeatureModule, + FeatureService, + FeatureType, +} from '../src/modules/features'; +import { PrismaModule } from '../src/prisma'; +import { RateLimiterModule } from '../src/throttler'; +import { initFeatureConfigs } from './utils'; + +const test = ava as TestFn<{ + auth: AuthService; + feature: FeatureService; + early_access: FeatureManagementService; + app: TestingModule; +}>; + +// cleanup database before each test +test.beforeEach(async () => { + const client = new PrismaClient(); + await client.$connect(); + await client.user.deleteMany({}); + await client.$disconnect(); +}); + +test.beforeEach(async t => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + auth: { + accessTokenExpiresIn: 1, + refreshTokenExpiresIn: 1, + leeway: 1, + }, + host: 'example.org', + https: true, + featureFlags: { + earlyAccessPreview: true, + }, + }), + PrismaModule, + AuthModule, + FeatureModule, + RateLimiterModule, + RevertCommand, + RunCommand, + ], + }).compile(); + + t.context.app = module; + t.context.auth = module.get(AuthService); + t.context.feature = module.get(FeatureService); + t.context.early_access = module.get(FeatureManagementService); + + // init features + await initFeatureConfigs(module); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test('should be able to set feature', async t => { + const { auth, feature } = t.context; + + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + const f1 = await feature.getUserFeatures(u1.id); + t.is(f1.length, 0, 'should be empty'); + + await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 1, 'test'); + + const f2 = await feature.getUserFeatures(u1.id); + t.is(f2.length, 1, 'should have 1 feature'); + t.is(f2[0].feature.name, FeatureType.EarlyAccess, 'should be early access'); +}); + +test('should be able to check early access', async t => { + const { auth, feature, early_access } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + const f1 = await early_access.canEarlyAccess(u1.email); + t.false(f1, 'should not have early access'); + + await early_access.addEarlyAccess(u1.id); + const f2 = await early_access.canEarlyAccess(u1.email); + t.true(f2, 'should have early access'); + + const f3 = await feature.listFeatureUsers(FeatureType.EarlyAccess); + t.is(f3.length, 1, 'should have 1 user'); + t.is(f3[0].id, u1.id, 'should be the same user'); +}); + +test('should be able revert quota', async t => { + const { auth, feature, early_access } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + const f1 = await early_access.canEarlyAccess(u1.email); + t.false(f1, 'should not have early access'); + + await early_access.addEarlyAccess(u1.id); + const f2 = await early_access.canEarlyAccess(u1.email); + t.true(f2, 'should have early access'); + const q1 = await early_access.listEarlyAccess(); + t.is(q1.length, 1, 'should have 1 user'); + t.is(q1[0].id, u1.id, 'should be the same user'); + + await early_access.removeEarlyAccess(u1.id); + const f3 = await early_access.canEarlyAccess(u1.email); + t.false(f3, 'should not have early access'); + const q2 = await early_access.listEarlyAccess(); + t.is(q2.length, 0, 'should have no user'); + + const q3 = await feature.getUserFeatures(u1.id); + t.is(q3.length, 1, 'should have 1 feature'); + t.is(q3[0].feature.name, FeatureType.EarlyAccess, 'should be early access'); + t.is(q3[0].activated, false, 'should be deactivated'); +}); + +test('should be same instance after reset the feature', async t => { + const { auth, feature, early_access } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + await early_access.addEarlyAccess(u1.id); + const f1 = (await feature.getUserFeatures(u1.id))[0]; + + await early_access.removeEarlyAccess(u1.id); + + await early_access.addEarlyAccess(u1.id); + const f2 = (await feature.getUserFeatures(u1.id))[1]; + + t.is(f1.feature, f2.feature, 'should be same instance'); +}); diff --git a/packages/backend/server/tests/history.spec.ts b/packages/backend/server/tests/history.spec.ts index 0c77b1facd..d496816461 100644 --- a/packages/backend/server/tests/history.spec.ts +++ b/packages/backend/server/tests/history.spec.ts @@ -8,8 +8,9 @@ import * as Sinon from 'sinon'; import { ConfigModule } from '../src/config'; import type { EventPayload } from '../src/event'; import { DocHistoryManager } from '../src/modules/doc'; +import { QuotaModule } from '../src/modules/quota'; import { PrismaModule, PrismaService } from '../src/prisma'; -import { flushDB } from './utils'; +import { FakeStorageModule, flushDB } from './utils'; let app: INestApplication; let m: TestingModule; @@ -20,7 +21,13 @@ let db: PrismaService; test.beforeEach(async () => { await flushDB(); m = await Test.createTestingModule({ - imports: [PrismaModule, ScheduleModule.forRoot(), ConfigModule.forRoot()], + imports: [ + PrismaModule, + QuotaModule, + FakeStorageModule.forRoot(), + ScheduleModule.forRoot(), + ConfigModule.forRoot(), + ], providers: [DocHistoryManager], }).compile(); @@ -277,8 +284,8 @@ test('should be able to recover from history', async t => { t.is(history2.timestamp.getTime(), snapshot.updatedAt.getTime()); // new history data force created with snapshot state before recovered - t.deepEqual(history2?.blob, Buffer.from([1, 1])); - t.deepEqual(history2?.state, Buffer.from([1, 1])); + t.deepEqual(history2.blob, Buffer.from([1, 1])); + t.deepEqual(history2.state, Buffer.from([1, 1])); }); test('should be able to cleanup expired history', async t => { diff --git a/packages/backend/server/tests/mailer.e2e.ts b/packages/backend/server/tests/mailer.e2e.ts index 10b2193252..ea8beea45f 100644 --- a/packages/backend/server/tests/mailer.e2e.ts +++ b/packages/backend/server/tests/mailer.e2e.ts @@ -11,11 +11,13 @@ import { PrismaClient } from '@prisma/client'; import ava, { type TestFn } from 'ava'; import { ConfigModule } from '../src/config'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; import { GqlModule } from '../src/graphql.module'; import { AuthModule } from '../src/modules/auth'; import { AuthService } from '../src/modules/auth/service'; import { PrismaModule } from '../src/prisma'; import { RateLimiterModule } from '../src/throttler'; +import { initFeatureConfigs } from './utils'; const test = ava as TestFn<{ auth: AuthService; @@ -45,8 +47,12 @@ test.beforeEach(async t => { AuthModule, RateLimiterModule, ], + providers: [RevertCommand, RunCommand], }).compile(); t.context.auth = t.context.module.get(AuthService); + + // init features + await initFeatureConfigs(t.context.module); }); test.afterEach.always(async t => { diff --git a/packages/backend/server/tests/mailer.spec.ts b/packages/backend/server/tests/mailer.spec.ts index 8d8c395c54..023cfdf35d 100644 --- a/packages/backend/server/tests/mailer.spec.ts +++ b/packages/backend/server/tests/mailer.spec.ts @@ -9,6 +9,7 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule } from '../src/app'; import { MailService } from '../src/modules/auth/mailer'; +import { FeatureManagementService } from '../src/modules/features'; import { PrismaService } from '../src/prisma'; import { createWorkspace, getInviteInfo, inviteUser, signUp } from './utils'; @@ -100,6 +101,8 @@ test.beforeEach(async t => { }) .overrideProvider(PrismaService) .useValue(FakePrisma) + .overrideProvider(FeatureManagementService) + .useValue({}) .compile(); const app = module.createNestApplication(); app.use( diff --git a/packages/backend/server/tests/quota.spec.ts b/packages/backend/server/tests/quota.spec.ts new file mode 100644 index 0000000000..f9d974929e --- /dev/null +++ b/packages/backend/server/tests/quota.spec.ts @@ -0,0 +1,133 @@ +/// + +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { type TestFn } from 'ava'; + +import { ConfigModule } from '../src/config'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; +import { AuthModule } from '../src/modules/auth'; +import { AuthService } from '../src/modules/auth/service'; +import { + QuotaManagementService, + QuotaModule, + Quotas, + QuotaService, + QuotaType, +} from '../src/modules/quota'; +import { PrismaModule } from '../src/prisma'; +import { RateLimiterModule } from '../src/throttler'; +import { FakeStorageModule, initFeatureConfigs } from './utils'; + +const test = ava as TestFn<{ + auth: AuthService; + quota: QuotaService; + storageQuota: QuotaManagementService; + app: TestingModule; +}>; + +// cleanup database before each test +test.beforeEach(async () => { + const client = new PrismaClient(); + await client.$connect(); + await client.user.deleteMany({}); + await client.$disconnect(); +}); + +test.beforeEach(async t => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + auth: { + accessTokenExpiresIn: 1, + refreshTokenExpiresIn: 1, + leeway: 1, + }, + host: 'example.org', + https: true, + }), + PrismaModule, + AuthModule, + QuotaModule, + FakeStorageModule.forRoot(), + RateLimiterModule, + RevertCommand, + RunCommand, + ], + }).compile(); + + const quota = module.get(QuotaService); + const storageQuota = module.get(QuotaManagementService); + const auth = module.get(AuthService); + + t.context.app = module; + t.context.quota = quota; + t.context.storageQuota = storageQuota; + t.context.auth = auth; + + // init features + await initFeatureConfigs(module); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test('should be able to set quota', async t => { + const { auth, quota } = t.context; + + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + const q1 = await quota.getUserQuota(u1.id); + t.truthy(q1, 'should have quota'); + t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan'); + + await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); + + const q2 = await quota.getUserQuota(u1.id); + t.is(q2?.feature.name, QuotaType.ProPlanV1, 'should be pro plan'); + + const fail = quota.switchUserQuota(u1.id, 'not_exists_plan_v1' as QuotaType); + await t.throwsAsync(fail, { instanceOf: Error }, 'should throw error'); +}); + +test('should be able to check storage quota', async t => { + const { auth, quota, storageQuota } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + const q1 = await storageQuota.getUserQuota(u1.id); + t.is(q1?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan'); + t.is(q1?.storageQuota, Quotas[0].configs.storageQuota, 'should be free plan'); + + await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); + const q2 = await storageQuota.getUserQuota(u1.id); + t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan'); + t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan'); +}); + +test('should be able revert quota', async t => { + const { auth, quota, storageQuota } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + const q1 = await storageQuota.getUserQuota(u1.id); + t.is(q1?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan'); + t.is(q1?.storageQuota, Quotas[0].configs.storageQuota, 'should be free plan'); + + await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); + const q2 = await storageQuota.getUserQuota(u1.id); + t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan'); + t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan'); + + await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1); + const q3 = await storageQuota.getUserQuota(u1.id); + t.is(q3?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan'); + + const quotas = await quota.getUserQuotas(u1.id); + t.is(quotas.length, 3, 'should have 3 quotas'); + t.is(quotas[0].feature.name, QuotaType.FreePlanV1, 'should be free plan'); + t.is(quotas[1].feature.name, QuotaType.ProPlanV1, 'should be pro plan'); + t.is(quotas[2].feature.name, QuotaType.FreePlanV1, 'should be free plan'); + t.is(quotas[0].activated, false, 'should be activated'); + t.is(quotas[1].activated, false, 'should be activated'); + t.is(quotas[2].activated, true, 'should be activated'); +}); diff --git a/packages/backend/server/tests/user.e2e.ts b/packages/backend/server/tests/user.e2e.ts index 065925af21..7087ba3f9f 100644 --- a/packages/backend/server/tests/user.e2e.ts +++ b/packages/backend/server/tests/user.e2e.ts @@ -6,7 +6,8 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import request from 'supertest'; import { AppModule } from '../src/app'; -import { currentUser, signUp } from './utils'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; +import { currentUser, initFeatureConfigs, signUp } from './utils'; let app: INestApplication; @@ -21,6 +22,7 @@ test.beforeEach(async () => { test.beforeEach(async () => { const module = await Test.createTestingModule({ imports: [AppModule], + providers: [RevertCommand, RunCommand], }).compile(); app = module.createNestApplication(); app.use( @@ -30,6 +32,9 @@ test.beforeEach(async () => { }) ); await app.init(); + + // init features + await initFeatureConfigs(module); }); test.afterEach.always(async () => { diff --git a/packages/backend/server/tests/utils.ts b/packages/backend/server/tests/utils.ts deleted file mode 100644 index 916195b244..0000000000 --- a/packages/backend/server/tests/utils.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { INestApplication } from '@nestjs/common'; -import { hashSync } from '@node-rs/argon2'; -import { PrismaClient, type User } from '@prisma/client'; -import request from 'supertest'; - -import type { TokenType } from '../src/modules/auth'; -import type { UserType } from '../src/modules/users'; -import type { InvitationType, WorkspaceType } from '../src/modules/workspaces'; - -const gql = '/graphql'; - -async function signUp( - app: INestApplication, - name: string, - email: string, - password: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - signUp(name: "${name}", email: "${email}", password: "${password}") { - id, name, email, token { token } - } - } - `, - }) - .expect(200); - return res.body.data.signUp; -} - -async function currentUser(app: INestApplication, token: string) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - currentUser { - id, name, email, emailVerified, avatarUrl, createdAt, hasPassword, - token { token } - } - } - `, - }) - .expect(200); - return res.body.data.currentUser; -} - -async function createWorkspace( - app: INestApplication, - token: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .field( - 'operations', - JSON.stringify({ - name: 'createWorkspace', - query: `mutation createWorkspace($init: Upload!) { - createWorkspace(init: $init) { - id - } - }`, - variables: { init: null }, - }) - ) - .field('map', JSON.stringify({ '0': ['variables.init'] })) - .attach('0', Buffer.from([0, 0]), 'init.data') - .expect(200); - return res.body.data.createWorkspace; -} - -export async function getWorkspacePublicPages( - app: INestApplication, - token: string, - workspaceId: string -) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - workspace(id: "${workspaceId}") { - publicPages { - id - mode - } - } - } - `, - }) - .expect(200); - return res.body.data.workspace.publicPages; -} - -async function getWorkspace( - app: INestApplication, - token: string, - workspaceId: string, - skip = 0, - take = 8 -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - workspace(id: "${workspaceId}") { - id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId } - } - } - `, - }) - .expect(200); - return res.body.data.workspace; -} - -async function getPublicWorkspace( - app: INestApplication, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - publicWorkspace(id: "${workspaceId}") { - id - } - } - `, - }) - .expect(200); - return res.body.data.publicWorkspace; -} - -async function updateWorkspace( - app: INestApplication, - token: string, - workspaceId: string, - isPublic: boolean -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) { - public - } - } - `, - }) - .expect(200); - return res.body.data.updateWorkspace.public; -} - -async function inviteUser( - app: INestApplication, - token: string, - workspaceId: string, - email: string, - permission: string, - sendInviteMail = false -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail}) - } - `, - }) - .expect(200); - return res.body.data.invite; -} - -async function acceptInviteById( - app: INestApplication, - workspaceId: string, - inviteId: string, - sendAcceptMail = false -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail}) - } - `, - }) - .expect(200); - return res.body.data.acceptInviteById; -} - -async function leaveWorkspace( - app: INestApplication, - token: string, - workspaceId: string, - sendLeaveMail = false -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail}) - } - `, - }) - .expect(200); - return res.body.data.leaveWorkspace; -} - -async function revokeUser( - app: INestApplication, - token: string, - workspaceId: string, - userId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - revoke(workspaceId: "${workspaceId}", userId: "${userId}") - } - `, - }) - .expect(200); - return res.body.data.revoke; -} - -async function publishPage( - app: INestApplication, - token: string, - workspaceId: string, - pageId: string -) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { - id - mode - } - } - `, - }) - .expect(200); - return res.body.errors?.[0]?.message || res.body.data?.publishPage; -} - -async function revokePublicPage( - app: INestApplication, - token: string, - workspaceId: string, - pageId: string -) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { - id - mode - public - } - } - `, - }) - .expect(200); - return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage; -} - -async function listBlobs( - app: INestApplication, - token: string, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - listBlobs(workspaceId: "${workspaceId}") - } - `, - }) - .expect(200); - return res.body.data.listBlobs; -} - -async function collectBlobSizes( - app: INestApplication, - token: string, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: ` - query { - collectBlobSizes(workspaceId: "${workspaceId}") { - size - } - } - `, - }) - .expect(200); - return res.body.data.collectBlobSizes.size; -} - -async function collectAllBlobSizes( - app: INestApplication, - token: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: ` - query { - collectAllBlobSizes { - size - } - } - `, - }) - .expect(200); - return res.body.data.collectAllBlobSizes.size; -} - -async function checkBlobSize( - app: INestApplication, - token: string, - workspaceId: string, - size: number -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: `query checkBlobSize($workspaceId: String!, $size: Float!) { - checkBlobSize(workspaceId: $workspaceId, size: $size) { - size - } - }`, - variables: { workspaceId, size }, - }) - .expect(200); - return res.body.data.checkBlobSize.size; -} - -async function setBlob( - app: INestApplication, - token: string, - workspaceId: string, - buffer: Buffer -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .field( - 'operations', - JSON.stringify({ - name: 'setBlob', - query: `mutation setBlob($blob: Upload!) { - setBlob(workspaceId: "${workspaceId}", blob: $blob) - }`, - variables: { blob: null }, - }) - ) - .field('map', JSON.stringify({ '0': ['variables.blob'] })) - .attach('0', buffer, 'blob.data') - .expect(200); - return res.body.data.setBlob; -} - -async function flushDB() { - const client = new PrismaClient(); - await client.$connect(); - const result: { tablename: string }[] = - await client.$queryRaw`SELECT tablename - FROM pg_catalog.pg_tables - WHERE schemaname != 'pg_catalog' - AND schemaname != 'information_schema'`; - - // remove all table data - await client.$executeRawUnsafe( - `TRUNCATE TABLE ${result - .map(({ tablename }) => tablename) - .filter(name => !name.includes('migrations')) - .join(', ')}` - ); - - await client.$disconnect(); -} - -async function getInviteInfo( - app: INestApplication, - token: string, - inviteId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - getInviteInfo(inviteId: "${inviteId}") { - workspace { - id - name - avatar - } - user { - id - name - avatarUrl - } - } - } - `, - }) - .expect(200); - return res.body.data.getInviteInfo; -} - -async function sendChangeEmail( - app: INestApplication, - userToken: string, - email: string, - callbackUrl: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(userToken, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}") - } - `, - }) - .expect(200); - - return res.body.data.sendChangeEmail; -} - -async function sendVerifyChangeEmail( - app: INestApplication, - userToken: string, - token: string, - email: string, - callbackUrl: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(userToken, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}") - } - `, - }) - .expect(200); - - return res.body.data.sendVerifyChangeEmail; -} - -async function changeEmail( - app: INestApplication, - userToken: string, - token: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(userToken, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - changeEmail(token: "${token}") { - id - name - avatarUrl - email - } - } - `, - }) - .expect(200); - return res.body.data.changeEmail; -} - -export class FakePrisma { - fakeUser: User = { - id: randomUUID(), - name: 'Alex Yang', - avatarUrl: '', - email: 'alex.yang@example.org', - password: hashSync('123456'), - emailVerified: new Date(), - createdAt: new Date(), - }; - - get user() { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const prisma = this; - return { - async findFirst() { - return prisma.fakeUser; - }, - async findUnique() { - return this.findFirst(); - }, - async update() { - return this.findFirst(); - }, - }; - } -} - -export { - acceptInviteById, - changeEmail, - checkBlobSize, - collectAllBlobSizes, - collectBlobSizes, - createWorkspace, - currentUser, - flushDB, - getInviteInfo, - getPublicWorkspace, - getWorkspace, - inviteUser, - leaveWorkspace, - listBlobs, - publishPage, - revokePublicPage, - revokeUser, - sendChangeEmail, - sendVerifyChangeEmail, - setBlob, - signUp, - updateWorkspace, -}; diff --git a/packages/backend/server/tests/utils/blobs.ts b/packages/backend/server/tests/utils/blobs.ts new file mode 100644 index 0000000000..c026a2922f --- /dev/null +++ b/packages/backend/server/tests/utils/blobs.ts @@ -0,0 +1,112 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import { gql } from './common'; + +export async function listBlobs( + app: INestApplication, + token: string, + workspaceId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + listBlobs(workspaceId: "${workspaceId}") + } + `, + }) + .expect(200); + return res.body.data.listBlobs; +} + +export async function getWorkspaceBlobsSize( + app: INestApplication, + token: string, + workspaceId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + query { + workspace(id: "${workspaceId}") { + blobsSize + } + } + `, + }) + .expect(200); + return res.body.data.workspace.blobsSize; +} + +export async function collectAllBlobSizes( + app: INestApplication, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + query { + collectAllBlobSizes { + size + } + } + `, + }) + .expect(200); + return res.body.data.collectAllBlobSizes.size; +} + +export async function checkBlobSize( + app: INestApplication, + token: string, + workspaceId: string, + size: number +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: `query checkBlobSize($workspaceId: String!, $size: Float!) { + checkBlobSize(workspaceId: $workspaceId, size: $size) { + size + } + }`, + variables: { workspaceId, size }, + }) + .expect(200); + return res.body.data.checkBlobSize.size; +} + +export async function setBlob( + app: INestApplication, + token: string, + workspaceId: string, + buffer: Buffer +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .field( + 'operations', + JSON.stringify({ + name: 'setBlob', + query: `mutation setBlob($blob: Upload!) { + setBlob(workspaceId: "${workspaceId}", blob: $blob) + }`, + variables: { blob: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.blob'] })) + .attach('0', buffer, 'blob.data') + .expect(200); + return res.body.data.setBlob; +} diff --git a/packages/backend/server/tests/utils/common.ts b/packages/backend/server/tests/utils/common.ts new file mode 100644 index 0000000000..7729223676 --- /dev/null +++ b/packages/backend/server/tests/utils/common.ts @@ -0,0 +1 @@ +export const gql = '/graphql'; diff --git a/packages/backend/server/tests/utils/index.ts b/packages/backend/server/tests/utils/index.ts new file mode 100644 index 0000000000..92904d3c35 --- /dev/null +++ b/packages/backend/server/tests/utils/index.ts @@ -0,0 +1,5 @@ +export * from './blobs'; +export * from './invite'; +export * from './user'; +export * from './utils'; +export * from './workspace'; diff --git a/packages/backend/server/tests/utils/invite.ts b/packages/backend/server/tests/utils/invite.ts new file mode 100644 index 0000000000..aeaacb2cec --- /dev/null +++ b/packages/backend/server/tests/utils/invite.ts @@ -0,0 +1,121 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import type { InvitationType } from '../../src/modules/workspaces'; +import { gql } from './common'; + +export async function inviteUser( + app: INestApplication, + token: string, + workspaceId: string, + email: string, + permission: string, + sendInviteMail = false +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail}) + } + `, + }) + .expect(200); + return res.body.data.invite; +} + +export async function acceptInviteById( + app: INestApplication, + workspaceId: string, + inviteId: string, + sendAcceptMail = false +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail}) + } + `, + }) + .expect(200); + return res.body.data.acceptInviteById; +} + +export async function leaveWorkspace( + app: INestApplication, + token: string, + workspaceId: string, + sendLeaveMail = false +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail}) + } + `, + }) + .expect(200); + return res.body.data.leaveWorkspace; +} + +export async function revokeUser( + app: INestApplication, + token: string, + workspaceId: string, + userId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + revoke(workspaceId: "${workspaceId}", userId: "${userId}") + } + `, + }) + .expect(200); + return res.body.data.revoke; +} + +export async function getInviteInfo( + app: INestApplication, + token: string, + inviteId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + getInviteInfo(inviteId: "${inviteId}") { + workspace { + id + name + avatar + } + user { + id + name + avatarUrl + } + } + } + `, + }) + .expect(200); + return res.body.data.getInviteInfo; +} diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts new file mode 100644 index 0000000000..64a374e816 --- /dev/null +++ b/packages/backend/server/tests/utils/user.ts @@ -0,0 +1,117 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import type { TokenType } from '../../src/modules/auth'; +import type { UserType } from '../../src/modules/users'; +import { gql } from './common'; + +export async function signUp( + app: INestApplication, + name: string, + email: string, + password: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + signUp(name: "${name}", email: "${email}", password: "${password}") { + id, name, email, token { token } + } + } + `, + }) + .expect(200); + return res.body.data.signUp; +} + +export async function currentUser(app: INestApplication, token: string) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + currentUser { + id, name, email, emailVerified, avatarUrl, createdAt, hasPassword, + token { token } + } + } + `, + }) + .expect(200); + return res.body.data.currentUser; +} + +export async function sendChangeEmail( + app: INestApplication, + userToken: string, + email: string, + callbackUrl: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}") + } + `, + }) + .expect(200); + + return res.body.data.sendChangeEmail; +} + +export async function sendVerifyChangeEmail( + app: INestApplication, + userToken: string, + token: string, + email: string, + callbackUrl: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}") + } + `, + }) + .expect(200); + + return res.body.data.sendVerifyChangeEmail; +} + +export async function changeEmail( + app: INestApplication, + userToken: string, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + changeEmail(token: "${token}") { + id + name + avatarUrl + email + } + } + `, + }) + .expect(200); + return res.body.data.changeEmail; +} diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts new file mode 100644 index 0000000000..0274e63daf --- /dev/null +++ b/packages/backend/server/tests/utils/utils.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; + +import type { DynamicModule, FactoryProvider } from '@nestjs/common'; +import { TestingModule } from '@nestjs/testing'; +import { hashSync } from '@node-rs/argon2'; +import { PrismaClient, type User } from '@prisma/client'; + +import { RevertCommand, RunCommand } from '../../src/data/commands/run'; +import { StorageProvide } from '../../src/storage'; + +export async function flushDB() { + const client = new PrismaClient(); + await client.$connect(); + const result: { tablename: string }[] = + await client.$queryRaw`SELECT tablename + FROM pg_catalog.pg_tables + WHERE schemaname != 'pg_catalog' + AND schemaname != 'information_schema'`; + + // remove all table data + await client.$executeRawUnsafe( + `TRUNCATE TABLE ${result + .map(({ tablename }) => tablename) + .filter(name => !name.includes('migrations')) + .join(', ')}` + ); + + await client.$disconnect(); +} + +export class FakePrisma { + fakeUser: User = { + id: randomUUID(), + name: 'Alex Yang', + avatarUrl: '', + email: 'alex.yang@example.org', + password: hashSync('123456'), + emailVerified: new Date(), + createdAt: new Date(), + }; + + get user() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const prisma = this; + return { + async findFirst() { + return prisma.fakeUser; + }, + async findUnique() { + return this.findFirst(); + }, + async update() { + return this.findFirst(); + }, + }; + } +} + +export class FakeStorageModule { + static forRoot(): DynamicModule { + const storageProvider: FactoryProvider = { + provide: StorageProvide, + useFactory: async () => { + return null; + }, + }; + + return { + global: true, + module: FakeStorageModule, + providers: [storageProvider], + exports: [storageProvider], + }; + } +} + +export async function initFeatureConfigs(module: TestingModule) { + const run = module.get(RunCommand); + const revert = module.get(RevertCommand); + await Promise.allSettled([revert.run(['UserFeaturesInit1698652531198'])]); + await run.runOne('UserFeaturesInit1698652531198'); +} diff --git a/packages/backend/server/tests/utils/workspace.ts b/packages/backend/server/tests/utils/workspace.ts new file mode 100644 index 0000000000..c8d5aa4789 --- /dev/null +++ b/packages/backend/server/tests/utils/workspace.ts @@ -0,0 +1,172 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import type { WorkspaceType } from '../../src/modules/workspaces'; +import { gql } from './common'; + +export async function createWorkspace( + app: INestApplication, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .field( + 'operations', + JSON.stringify({ + name: 'createWorkspace', + query: `mutation createWorkspace($init: Upload!) { + createWorkspace(init: $init) { + id + } + }`, + variables: { init: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.init'] })) + .attach('0', Buffer.from([0, 0]), 'init.data') + .expect(200); + return res.body.data.createWorkspace; +} + +export async function getWorkspacePublicPages( + app: INestApplication, + token: string, + workspaceId: string +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + workspace(id: "${workspaceId}") { + publicPages { + id + mode + } + } + } + `, + }) + .expect(200); + return res.body.data.workspace.publicPages; +} + +export async function getWorkspace( + app: INestApplication, + token: string, + workspaceId: string, + skip = 0, + take = 8 +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + workspace(id: "${workspaceId}") { + id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId } + } + } + `, + }) + .expect(200); + return res.body.data.workspace; +} + +export async function getPublicWorkspace( + app: INestApplication, + workspaceId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + publicWorkspace(id: "${workspaceId}") { + id + } + } + `, + }) + .expect(200); + return res.body.data.publicWorkspace; +} + +export async function updateWorkspace( + app: INestApplication, + token: string, + workspaceId: string, + isPublic: boolean +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) { + public + } + } + `, + }) + .expect(200); + return res.body.data.updateWorkspace.public; +} + +export async function publishPage( + app: INestApplication, + token: string, + workspaceId: string, + pageId: string +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { + id + mode + } + } + `, + }) + .expect(200); + return res.body.errors?.[0]?.message || res.body.data?.publishPage; +} + +export async function revokePublicPage( + app: INestApplication, + token: string, + workspaceId: string, + pageId: string +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { + id + mode + public + } + } + `, + }) + .expect(200); + return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage; +} diff --git a/packages/backend/server/tests/workspace-blobs.spec.ts b/packages/backend/server/tests/workspace-blobs.spec.ts index acd53ebe34..af0f683adc 100644 --- a/packages/backend/server/tests/workspace-blobs.spec.ts +++ b/packages/backend/server/tests/workspace-blobs.spec.ts @@ -6,17 +6,21 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import request from 'supertest'; import { AppModule } from '../src/app'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; +import { QuotaService, QuotaType } from '../src/modules/quota'; import { checkBlobSize, collectAllBlobSizes, - collectBlobSizes, createWorkspace, + getWorkspaceBlobsSize, + initFeatureConfigs, listBlobs, setBlob, signUp, } from './utils'; let app: INestApplication; +let quota: QuotaService; const client = new PrismaClient(); @@ -33,6 +37,7 @@ test.beforeEach(async () => { test.beforeEach(async () => { const module = await Test.createTestingModule({ imports: [AppModule], + providers: [RevertCommand, RunCommand], }).compile(); app = module.createNestApplication(); app.use( @@ -41,6 +46,11 @@ test.beforeEach(async () => { maxFiles: 5, }) ); + quota = module.get(QuotaService); + + // init features + await initFeatureConfigs(module); + await app.init(); }); @@ -103,7 +113,7 @@ test('should calc blobs size', async t => { const buffer2 = Buffer.from([0, 1]); await setBlob(app, u1.token.token, workspace.id, buffer2); - const size = await collectBlobSizes(app, u1.token.token, workspace.id); + const size = await getWorkspaceBlobsSize(app, u1.token.token, workspace.id); t.is(size, 4, 'failed to collect blob sizes'); }); @@ -143,3 +153,39 @@ test('should calc all blobs size', async t => { ); t.is(size2, -1, 'failed to check blob size'); }); + +test('should be able calc quota after switch plan', async t => { + const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1'); + + const workspace1 = await createWorkspace(app, u1.token.token); + + const buffer1 = Buffer.from([0, 0]); + await setBlob(app, u1.token.token, workspace1.id, buffer1); + const buffer2 = Buffer.from([0, 1]); + await setBlob(app, u1.token.token, workspace1.id, buffer2); + + const workspace2 = await createWorkspace(app, u1.token.token); + + const buffer3 = Buffer.from([0, 0]); + await setBlob(app, u1.token.token, workspace2.id, buffer3); + const buffer4 = Buffer.from([0, 1]); + await setBlob(app, u1.token.token, workspace2.id, buffer4); + + const size1 = await checkBlobSize( + app, + u1.token.token, + workspace1.id, + 10 * 1024 * 1024 * 1024 - 8 + ); + t.is(size1, 0, 'failed to check free plan blob size'); + + quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); + + const size2 = await checkBlobSize( + app, + u1.token.token, + workspace1.id, + 100 * 1024 * 1024 * 1024 - 8 + ); + t.is(size2, 0, 'failed to check pro plan blob size'); +}); diff --git a/packages/backend/server/tests/workspace-invite.e2e.ts b/packages/backend/server/tests/workspace-invite.e2e.ts index dcaf623e18..52a6eac694 100644 --- a/packages/backend/server/tests/workspace-invite.e2e.ts +++ b/packages/backend/server/tests/workspace-invite.e2e.ts @@ -9,12 +9,14 @@ import ava, { type TestFn } from 'ava'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule } from '../src/app'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; import { MailService } from '../src/modules/auth/mailer'; import { AuthService } from '../src/modules/auth/service'; import { acceptInviteById, createWorkspace, getWorkspace, + initFeatureConfigs, inviteUser, leaveWorkspace, revokeUser, @@ -39,6 +41,7 @@ test.beforeEach(async t => { await client.$disconnect(); const module = await Test.createTestingModule({ imports: [AppModule], + providers: [RevertCommand, RunCommand], }).compile(); const app = module.createNestApplication(); app.use( @@ -51,9 +54,13 @@ test.beforeEach(async t => { const auth = module.get(AuthService); const mail = module.get(MailService); + t.context.app = app; t.context.auth = auth; t.context.mail = mail; + + // init features + await initFeatureConfigs(module); }); test.afterEach.always(async t => { diff --git a/packages/backend/server/tests/workspace-usage.spec.ts b/packages/backend/server/tests/workspace-usage.spec.ts index a7051b1eb0..ad5623b393 100644 --- a/packages/backend/server/tests/workspace-usage.spec.ts +++ b/packages/backend/server/tests/workspace-usage.spec.ts @@ -4,6 +4,8 @@ import ava, { type TestFn } from 'ava'; import { stub } from 'sinon'; import { AppModule } from '../src/app'; +import { FeatureManagementService } from '../src/modules/features'; +import { Quotas } from '../src/modules/quota'; import { UsersService } from '../src/modules/users'; import { PermissionService } from '../src/modules/workspaces/permission'; import { WorkspaceResolver } from '../src/modules/workspaces/resolver'; @@ -20,6 +22,9 @@ class FakePermission { user: new FakePrisma().fakeUser, }; } + async getOwnedWorkspaces() { + return ['']; + } } const fakeUserService = { @@ -42,6 +47,36 @@ test.beforeEach(async t => { return []; }, }, + userFeatures: { + async count() { + return 1; + }, + async findFirst() { + return { + createdAt: new Date(), + expiredAt: new Date(), + reason: '', + feature: Quotas[0], + }; + }, + }, + features: { + async findFirst() { + return { + id: 0, + feature: 'free_plan_v1', + version: 1, + type: 1, + configs: { + name: 'Free', + blobLimit: 1, + storageQuota: 1, + historyPeriod: 1, + memberLimit: 3, + }, + }; + }, + }, }) .overrideProvider(PermissionService) .useClass(FakePermission) @@ -53,6 +88,8 @@ test.beforeEach(async t => { return 1024 * 10; }, }) + .overrideProvider(FeatureManagementService) + .useValue({}) .compile(); t.context.app = module.createNestApplication(); t.context.resolver = t.context.app.get(WorkspaceResolver); diff --git a/packages/backend/server/tests/workspace.e2e.ts b/packages/backend/server/tests/workspace.e2e.ts index 41bf97befc..86904acc6a 100644 --- a/packages/backend/server/tests/workspace.e2e.ts +++ b/packages/backend/server/tests/workspace.e2e.ts @@ -6,12 +6,14 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import request from 'supertest'; import { AppModule } from '../src/app'; +import { RevertCommand, RunCommand } from '../src/data/commands/run'; import { acceptInviteById, createWorkspace, currentUser, getPublicWorkspace, getWorkspacePublicPages, + initFeatureConfigs, inviteUser, publishPage, revokePublicPage, @@ -34,6 +36,7 @@ test.beforeEach(async t => { await client.$disconnect(); const module = await Test.createTestingModule({ imports: [AppModule], + providers: [RevertCommand, RunCommand], }).compile(); const app = module.createNestApplication(); app.use( @@ -45,6 +48,9 @@ test.beforeEach(async t => { await app.init(); t.context.client = client; t.context.app = app; + + // init features + await initFeatureConfigs(module); }); test.afterEach.always(async t => { diff --git a/packages/backend/storage/package.json b/packages/backend/storage/package.json index a652ada5b1..e476113ebe 100644 --- a/packages/backend/storage/package.json +++ b/packages/backend/storage/package.json @@ -1,6 +1,6 @@ { "name": "@affine/storage", - "version": "0.10.3", + "version": "0.11.0", "engines": { "node": ">= 10.16.0 < 11 || >= 11.8.0" }, diff --git a/packages/common/cmdk/package.json b/packages/common/cmdk/package.json index 2b3794e7c4..e1defb7226 100644 --- a/packages/common/cmdk/package.json +++ b/packages/common/cmdk/package.json @@ -8,5 +8,5 @@ "react": "18.2.0", "react-dom": "18.2.0" }, - "version": "0.10.3" + "version": "0.11.0" } diff --git a/packages/common/debug/package.json b/packages/common/debug/package.json index ba9a221f94..e8b4fb4f7e 100644 --- a/packages/common/debug/package.json +++ b/packages/common/debug/package.json @@ -7,7 +7,7 @@ }, "devDependencies": { "@types/debug": "^4.1.9", - "vitest": "0.34.6" + "vitest": "1.0.4" }, - "version": "0.10.3" + "version": "0.11.0" } diff --git a/packages/common/env/package.json b/packages/common/env/package.json index 95928bda83..b20bc69bf1 100644 --- a/packages/common/env/package.json +++ b/packages/common/env/package.json @@ -3,11 +3,11 @@ "private": true, "type": "module", "devDependencies": { - "@blocksuite/global": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/store": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/global": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/store": "0.11.0-nightly-202312200102-8254dc9", "react": "18.2.0", "react-dom": "18.2.0", - "vitest": "0.34.6", + "vitest": "1.0.4", "zod": "^3.22.4" }, "exports": { @@ -26,5 +26,5 @@ "dependencies": { "lit": "^3.0.2" }, - "version": "0.10.3" + "version": "0.11.0" } diff --git a/packages/common/env/src/constant.ts b/packages/common/env/src/constant.ts index 28938bfb3a..179ec41abb 100644 --- a/packages/common/env/src/constant.ts +++ b/packages/common/env/src/constant.ts @@ -6,6 +6,7 @@ declare global { appInfo: { electron: boolean; schema: string; + windowName: string; }; } } diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 0532ff1248..8709d98008 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -1,12 +1,12 @@ /// import { assertEquals } from '@blocksuite/global/utils'; +import type { Workspace } from '@blocksuite/store'; import { z } from 'zod'; import { isDesktop, isServer } from './constant.js'; import { UaHelper } from './ua-helper.js'; export const blockSuiteFeatureFlags = z.object({ - enable_transformer_clipboard: z.boolean(), enable_expand_database_block: z.boolean(), enable_bultin_ledits: z.boolean(), }); @@ -31,6 +31,7 @@ export const runtimeFlagsSchema = z.object({ enableEnhanceShareMode: z.boolean(), enablePayment: z.boolean(), enablePageHistory: z.boolean(), + enableCopilot: z.boolean(), // this is for the electron app serverUrlPrefix: z.string(), enableMoveDatabase: z.boolean(), @@ -150,3 +151,12 @@ export function setupGlobal() { globalThis.$AFFINE_SETUP = true; } + +export function setupEditorFlags(workspace: Workspace) { + Object.entries(runtimeConfig.editorFlags).forEach(([key, value]) => { + workspace.awarenessStore.setFlag( + key as keyof BlockSuiteFeatureFlags, + value + ); + }); +} diff --git a/packages/common/env/src/workspace.ts b/packages/common/env/src/workspace.ts index 4ac9e7d4d3..9f99f5c077 100644 --- a/packages/common/env/src/workspace.ts +++ b/packages/common/env/src/workspace.ts @@ -1,10 +1,4 @@ -import type { - ActiveDocProvider, - PassiveDocProvider, - Workspace as BlockSuiteWorkspace, -} from '@blocksuite/store'; import type { PropsWithChildren, ReactNode } from 'react'; -import type { DataSourceAdapter } from 'y-provider'; export enum WorkspaceSubPath { ALL = 'all', @@ -14,73 +8,6 @@ export enum WorkspaceSubPath { SHARED = 'shared', } -export interface AffineDownloadProvider extends PassiveDocProvider { - flavour: 'affine-download'; -} - -/** - * Download the first binary from local IndexedDB - */ -export interface BroadCastChannelProvider extends PassiveDocProvider { - flavour: 'broadcast-channel'; -} - -/** - * Long polling provider with local IndexedDB - */ -export interface LocalIndexedDBBackgroundProvider - extends DataSourceAdapter, - PassiveDocProvider { - flavour: 'local-indexeddb-background'; -} - -export interface LocalIndexedDBDownloadProvider extends ActiveDocProvider { - flavour: 'local-indexeddb'; -} - -export interface SQLiteProvider extends PassiveDocProvider, DataSourceAdapter { - flavour: 'sqlite'; -} - -export interface SQLiteDBDownloadProvider extends ActiveDocProvider { - flavour: 'sqlite-download'; -} - -export interface AffineSocketIOProvider - extends PassiveDocProvider, - DataSourceAdapter { - flavour: 'affine-socket-io'; -} - -type BaseWorkspace = { - flavour: string; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -}; - -export interface AffineCloudWorkspace extends BaseWorkspace { - flavour: WorkspaceFlavour.AFFINE_CLOUD; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -} - -export interface LocalWorkspace extends BaseWorkspace { - flavour: WorkspaceFlavour.LOCAL; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -} - -export interface AffinePublicWorkspace extends BaseWorkspace { - flavour: WorkspaceFlavour.AFFINE_PUBLIC; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -} - -export type AffineOfficialWorkspace = - | AffineCloudWorkspace - | LocalWorkspace - | AffinePublicWorkspace; - export enum ReleaseType { // if workspace is not released yet, we will not show it in the workspace list UNRELEASED = 'unreleased', @@ -99,7 +26,6 @@ export enum WorkspaceFlavour { */ AFFINE_CLOUD = 'affine-cloud', LOCAL = 'local', - AFFINE_PUBLIC = 'affine-public', } export const settingPanel = { @@ -112,68 +38,30 @@ export const settingPanel = { export const settingPanelValues = Object.values(settingPanel); export type SettingPanel = (typeof settingPanel)[keyof typeof settingPanel]; -// built-in workspaces -export interface WorkspaceRegistry { - [WorkspaceFlavour.LOCAL]: LocalWorkspace; - [WorkspaceFlavour.AFFINE_PUBLIC]: AffinePublicWorkspace; - [WorkspaceFlavour.AFFINE_CLOUD]: AffineCloudWorkspace; -} - -export interface WorkspaceCRUD { - create: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise; - delete: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise; - get: (workspaceId: string) => Promise; - // not supported yet - // update: (workspace: FlavourToWorkspace[Flavour]) => Promise; - list: () => Promise; -} - -type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = { - currentWorkspaceId: string; +export type WorkspaceHeaderProps = { + rightSlot?: ReactNode; + currentEntry: + | { + subPath: WorkspaceSubPath; + } + | { + pageId: string; + }; }; -type NewSettingProps = - UIBaseProps & { - onDeleteLocalWorkspace: () => void; - onDeleteCloudWorkspace: () => void; - onLeaveWorkspace: () => void; - onTransformWorkspace: < - From extends keyof WorkspaceRegistry, - To extends keyof WorkspaceRegistry, - >( - from: From, - to: To, - workspace: WorkspaceRegistry[From] - ) => void; - }; - interface FC

{ (props: P): ReactNode; } -export interface WorkspaceUISchema { - NewSettingsDetail: FC>; +export interface WorkspaceUISchema { Provider: FC; LoginCard?: FC; } -export interface AppEvents { - // event there is no workspace - // usually used to initialize workspace adapter - 'app:init': () => string[]; - // event if you have access to workspace adapter - 'app:access': () => Promise; - 'service:start': () => void; - 'service:stop': () => void; -} - export interface WorkspaceAdapter { releaseType: ReleaseType; flavour: Flavour; // The Adapter will be loaded according to the priority loadPriority: LoadPriority; - Events: Partial; - // Fetch necessary data for the first render - CRUD: WorkspaceCRUD; - UI: WorkspaceUISchema; + UI: WorkspaceUISchema; } diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index 7c52aea08d..7efeb0e237 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -40,6 +40,11 @@ "import": "./dist/type.js", "require": "./dist/type.cjs" }, + "./app-config-storage": { + "type": "./dist/src/app-config-storage.d.ts", + "import": "./dist/app-config-storage.js", + "require": "./dist/app-config-storage.cjs" + }, "./__internal__/*": { "type": "./dist/src/__internal__/*.d.ts", "import": "./dist/__internal__/*.js", @@ -57,9 +62,9 @@ "@affine/debug": "workspace:*", "@affine/env": "workspace:*", "@affine/sdk": "workspace:*", - "@blocksuite/blocks": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/global": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/store": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/blocks": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/global": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/store": "0.11.0-nightly-202312200102-8254dc9", "jotai": "^2.5.1", "jotai-effect": "^0.2.3", "tinykeys": "^2.1.0", @@ -68,8 +73,8 @@ "devDependencies": { "@affine-test/fixtures": "workspace:*", "@affine/templates": "workspace:*", - "@blocksuite/lit": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/presets": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/lit": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/presets": "0.11.0-nightly-202312200102-8254dc9", "@testing-library/react": "^14.0.0", "async-call-rpc": "^6.3.1", "electron": "link:../../frontend/electron/node_modules/electron", @@ -78,7 +83,7 @@ "rxjs": "^7.8.1", "vite": "^5.0.6", "vite-plugin-dts": "3.6.0", - "vitest": "0.34.6", + "vitest": "1.0.4", "yjs": "^13.6.10" }, "peerDependencies": { @@ -109,5 +114,5 @@ "optional": true } }, - "version": "0.10.3" + "version": "0.11.0" } diff --git a/packages/common/infra/src/__internal__/workspace.ts b/packages/common/infra/src/__internal__/workspace.ts deleted file mode 100644 index e788702d84..0000000000 --- a/packages/common/infra/src/__internal__/workspace.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ActiveDocProvider, Workspace } from '@blocksuite/store'; -import type { PassiveDocProvider } from '@blocksuite/store'; -import type { Atom } from 'jotai/vanilla'; -import { atom } from 'jotai/vanilla'; -import { atomEffect } from 'jotai-effect'; - -/** - * Map: guid -> Workspace - */ -export const INTERNAL_BLOCKSUITE_HASH_MAP = new Map([]); - -const workspaceActiveAtomWeakMap = new WeakMap< - Workspace, - Atom> ->(); - -const workspaceActiveWeakMap = new WeakMap(); -const workspaceEffectAtomWeakMap = new WeakMap>(); - -export async function waitForWorkspace(workspace: Workspace) { - if (workspaceActiveWeakMap.get(workspace) !== true) { - const providers = workspace.providers.filter( - (provider): provider is ActiveDocProvider => - 'active' in provider && provider.active === true - ); - for (const provider of providers) { - provider.sync(); - // we will wait for the necessary providers to be ready - await provider.whenReady; - } - // timeout is INFINITE - workspaceActiveWeakMap.set(workspace, true); - } -} - -export function getWorkspace(id: string) { - if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) { - throw new Error('Workspace not found'); - } - return INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace; -} - -export function getBlockSuiteWorkspaceAtom( - id: string -): [workspaceAtom: Atom>, workspaceEffectAtom: Atom] { - if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) { - throw new Error('Workspace not found'); - } - const workspace = INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace; - if (!workspaceActiveAtomWeakMap.has(workspace)) { - const baseAtom = atom(async () => { - await waitForWorkspace(workspace); - return workspace; - }); - workspaceActiveAtomWeakMap.set(workspace, baseAtom); - } - if (!workspaceEffectAtomWeakMap.has(workspace)) { - const effectAtom = atomEffect(() => { - const providers = workspace.providers.filter( - (provider): provider is PassiveDocProvider => - 'passive' in provider && provider.passive === true - ); - providers.forEach(provider => { - provider.connect(); - }); - return () => { - providers.forEach(provider => { - provider.disconnect(); - }); - }; - }); - workspaceEffectAtomWeakMap.set(workspace, effectAtom); - } - - return [ - workspaceActiveAtomWeakMap.get(workspace) as Atom>, - workspaceEffectAtomWeakMap.get(workspace) as Atom, - ]; -} diff --git a/packages/common/infra/src/__tests__/blocksuite-atom.ts b/packages/common/infra/src/__tests__/blocksuite-atom.ts deleted file mode 100644 index 9644d35b89..0000000000 --- a/packages/common/infra/src/__tests__/blocksuite-atom.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { Schema, Workspace } from '@blocksuite/store'; -import { waitFor } from '@testing-library/react'; -import { getDefaultStore } from 'jotai/vanilla'; -import { expect, test, vi } from 'vitest'; - -import { - getBlockSuiteWorkspaceAtom, - INTERNAL_BLOCKSUITE_HASH_MAP, -} from '../__internal__/workspace.js'; - -test('blocksuite atom', async () => { - const sync = vi.fn(); - let connected = false; - const connect = vi.fn(() => (connected = true)); - const workspace = new Workspace({ - schema: new Schema(), - id: '1', - providerCreators: [ - () => ({ - flavour: 'fake', - active: true, - sync, - get whenReady(): Promise { - return Promise.resolve(); - }, - }), - () => ({ - flavour: 'fake-2', - passive: true, - get connected() { - return connected; - }, - connect, - disconnect: vi.fn(), - }), - ], - }); - INTERNAL_BLOCKSUITE_HASH_MAP.set('1', workspace); - - { - const [atom, effectAtom] = getBlockSuiteWorkspaceAtom('1'); - const store = getDefaultStore(); - const result = await store.get(atom); - expect(result).toBe(workspace); - expect(sync).toBeCalledTimes(1); - expect(connect).not.toHaveBeenCalled(); - - store.sub(effectAtom, vi.fn()); - await waitFor(() => expect(connect).toBeCalledTimes(1)); - expect(connected).toBe(true); - } -}); diff --git a/packages/common/infra/src/app-config-storage.ts b/packages/common/infra/src/app-config-storage.ts new file mode 100644 index 0000000000..19e045f7f3 --- /dev/null +++ b/packages/common/infra/src/app-config-storage.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +const _appConfigSchema = z.object({ + /** whether to show onboarding first */ + onBoarding: z.boolean().optional().default(true), + /** whether to show change workspace guide modal */ + dismissWorkspaceGuideModal: z.boolean().optional().default(false), +}); +export type AppConfigSchema = z.infer; +export const defaultAppConfig = _appConfigSchema.parse({}); + +const _storage: Record = {}; +let _inMemoryId = 0; + +interface StorageOptions { + /** default config */ + config: T; + get?: () => T; + set?: (data: T) => void; +} + +/** + * Storage for app configuration, stored in memory by default + */ +class Storage { + private _cfg: T; + private readonly _id = _inMemoryId++; + private readonly _options; + + constructor(options: StorageOptions) { + this._options = { + get: () => _storage[this._id], + set: (data: T) => (_storage[this._id] = data), + ...options, + }; + this._cfg = this.get() ?? options.config; + } + + /** + * update entire config + * @param data + */ + set(data: T) { + try { + this._options.set(data); + } catch (err) { + console.error('failed to save config', err); + } + this._cfg = data; + } + + get(): T; + get(key: keyof T): T[keyof T]; + /** + * get config, if key is provided, return the value of the key + * @param key + * @returns + */ + get(key?: keyof T): T | T[keyof T] { + if (!key) { + try { + const cfg = this._options.get(); + if (!cfg) { + this.set(this._options.config); + return this._options.config; + } + return cfg; + } catch (err) { + return this._cfg; + } + } else { + const fullConfig = this.get(); + // TODO: handle key not found, set default value + // if (!(key in fullConfig)) {} + return fullConfig[key]; + } + } + + /** + * update a key in config + * @param key + * @param value + */ + patch(key: keyof T, value: any) { + this.set({ ...this.get(), [key]: value }); + } + + get value(): T { + return this.get(); + } +} + +export class AppConfigStorage extends Storage {} diff --git a/packages/common/infra/src/atom/workspace.ts b/packages/common/infra/src/atom/workspace.ts index c73ab5a5e7..871333c835 100644 --- a/packages/common/infra/src/atom/workspace.ts +++ b/packages/common/infra/src/atom/workspace.ts @@ -1,14 +1,3 @@ -import { assertExists } from '@blocksuite/global/utils'; -import type { Workspace } from '@blocksuite/store'; import { atom } from 'jotai'; -import { getBlockSuiteWorkspaceAtom } from '../__internal__/workspace'; - -export const currentWorkspaceIdAtom = atom(null); export const currentPageIdAtom = atom(null); -export const currentWorkspaceAtom = atom>(async get => { - const workspaceId = get(currentWorkspaceIdAtom); - assertExists(workspaceId); - const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId); - return get(currentWorkspaceAtom); -}); diff --git a/packages/common/infra/src/blocksuite/index.ts b/packages/common/infra/src/blocksuite/index.ts index a74ca60193..09196653c0 100644 --- a/packages/common/infra/src/blocksuite/index.ts +++ b/packages/common/infra/src/blocksuite/index.ts @@ -1,8 +1,10 @@ export * from './initialization'; -export * from './migration/blob'; -export { migratePages as forceUpgradePages } from './migration/blocksuite'; // campatible with electron +export { + migratePages as forceUpgradePages, + migrateGuidCompatibility, +} from './migration/blocksuite'; // campatible with electron export * from './migration/fixing'; -export { migrateToSubdoc } from './migration/subdoc'; +export { migrateToSubdoc, upgradeV1ToV2 } from './migration/subdoc'; export * from './migration/workspace'; /** diff --git a/packages/common/infra/src/blocksuite/initialization/index.ts b/packages/common/infra/src/blocksuite/initialization/index.ts index ca06c51e5b..e596607b75 100644 --- a/packages/common/infra/src/blocksuite/initialization/index.ts +++ b/packages/common/infra/src/blocksuite/initialization/index.ts @@ -2,12 +2,9 @@ import { assertExists } from '@blocksuite/global/utils'; import type { Page, PageMeta, Workspace } from '@blocksuite/store'; import type { createStore, WritableAtom } from 'jotai/vanilla'; import { nanoid } from 'nanoid'; +import { Map as YMap } from 'yjs'; -import { migratePages } from '../migration/blocksuite'; -import { - checkWorkspaceCompatibility, - MigrationPoint, -} from '../migration/workspace'; +import { getLatestVersions } from '../migration/blocksuite'; export async function initEmptyPage(page: Page, title?: string) { await page.load(() => { @@ -89,12 +86,8 @@ export async function buildShowcaseWorkspace( }; workspace.meta.setProperties(prototypes); const edgelessPage1 = nanoid(); - const edgelessPage2 = nanoid(); - const edgelessPage3 = nanoid(); const { store, atoms } = options; - [edgelessPage1, edgelessPage2, edgelessPage3].forEach(pageId => { - store.set(atoms.pageMode, pageId, 'edgeless'); - }); + store.set(atoms.pageMode, edgelessPage1, 'edgeless'); const pageMetas = { '9f6f3c04-cf32-470c-9648-479dc838f10e': { @@ -133,11 +126,6 @@ export async function buildShowcaseWorkspace( tags: [], updatedDate: 1691646845195, }, - '512b1cb3-d22d-4b20-a7aa-58e2afcb1238': { - createDate: 1691574743531, - tags: ['icg1n5UdkP'], - updatedDate: 1691647117761, - }, '22163830-8252-43fe-b62d-fd9bbeaa4caa': { createDate: 1691574859042, tags: [], @@ -165,11 +153,6 @@ export async function buildShowcaseWorkspace( tags: ['q3mceOl_zi', 'g1L5dXKctL'], updatedDate: 1691645102104, }, - '9d6e716e-a071-45a2-88ac-2f2f6eec0109': { - createDate: 1691574743531, - tags: ['icg1n5UdkP'], - updatedDate: 1691574743531, - }, } satisfies Record>; const data = [ [ @@ -202,11 +185,6 @@ export async function buildShowcaseWorkspace( import('@affine/templates/v1/personal-project-management.json'), nanoid(), ], - [ - '512b1cb3-d22d-4b20-a7aa-58e2afcb1238', - import('@affine/templates/v1/travel-plan.json'), - edgelessPage2, - ], [ '22163830-8252-43fe-b62d-fd9bbeaa4caa', import('@affine/templates/v1/personal-knowledge-management.json'), @@ -232,11 +210,6 @@ export async function buildShowcaseWorkspace( import('@affine/templates/v1/okr-template.json'), nanoid(), ], - [ - '9d6e716e-a071-45a2-88ac-2f2f6eec0109', - import('@affine/templates/v1/travel-note.json'), - edgelessPage3, - ], ] as const; const idMap = await Promise.all(data).then(async data => { return data.reduce>( @@ -285,10 +258,11 @@ export async function buildShowcaseWorkspace( // The showcase building will create multiple pages once, and may skip the version writing. // https://github.com/toeverything/blocksuite/blob/master/packages/store/src/workspace/page.ts#L662 - const compatibilityResult = checkWorkspaceCompatibility(workspace); - if (compatibilityResult === MigrationPoint.BlockVersion) { - await migratePages(workspace.doc, workspace.schema); - } + workspace.doc.getMap('meta').set('pageVersion', 2); + const newVersions = getLatestVersions(workspace.schema); + workspace.doc + .getMap('meta') + .set('blockVersions', new YMap(Object.entries(newVersions))); Object.entries(pageMetas).forEach(([oldId, meta]) => { const newId = idMap[oldId]; diff --git a/packages/common/infra/src/blocksuite/migration/blob.ts b/packages/common/infra/src/blocksuite/migration/blob.ts deleted file mode 100644 index 45f5f10820..0000000000 --- a/packages/common/infra/src/blocksuite/migration/blob.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createIndexeddbStorage } from '@blocksuite/store'; - -export async function migrateLocalBlobStorage(from: string, to: string) { - const fromStorage = createIndexeddbStorage(from); - const toStorage = createIndexeddbStorage(to); - const keys = await fromStorage.crud.list(); - for (const key of keys) { - const value = await fromStorage.crud.get(key); - if (!value) { - console.warn('cannot find blob:', key); - continue; - } - await toStorage.crud.set(key, value); - } -} diff --git a/packages/common/infra/src/blocksuite/migration/blocksuite.ts b/packages/common/infra/src/blocksuite/migration/blocksuite.ts index ef61391512..a5e71013ea 100644 --- a/packages/common/infra/src/blocksuite/migration/blocksuite.ts +++ b/packages/common/infra/src/blocksuite/migration/blocksuite.ts @@ -1,8 +1,14 @@ import type { Schema } from '@blocksuite/store'; -import type { Doc as YDoc } from 'yjs'; -import { Map as YMap } from 'yjs'; +import type { Array as YArray } from 'yjs'; +import { + applyUpdate, + Doc as YDoc, + encodeStateAsUpdate, + Map as YMap, + transact, +} from 'yjs'; -const getLatestVersions = (schema: Schema): Record => { +export const getLatestVersions = (schema: Schema): Record => { return [...schema.flavourSchemaMap.entries()].reduce( (record, [flavour, schema]) => { record[flavour] = schema.version; @@ -28,14 +34,62 @@ export async function migratePages( // Hard code to upgrade page version to 2. // Let e2e to ensure the data version is correct. - const pageVersion = meta.get('pageVersion'); - if (typeof pageVersion !== 'number' || pageVersion < 2) { - meta.set('pageVersion', 2); - } + return transact( + rootDoc, + () => { + const pageVersion = meta.get('pageVersion'); + if (typeof pageVersion !== 'number' || pageVersion < 2) { + meta.set('pageVersion', 2); + } - const newVersions = getLatestVersions(schema); - meta.set('blockVersions', new YMap(Object.entries(newVersions))); - return Object.entries(oldVersions).some( - ([flavour, version]) => newVersions[flavour] !== version + const newVersions = getLatestVersions(schema); + meta.set('blockVersions', new YMap(Object.entries(newVersions))); + return Object.entries(oldVersions).some( + ([flavour, version]) => newVersions[flavour] !== version + ); + }, + 'migratePages', + /** + * transact as remote update, because blocksuite will skip local changes. + * https://github.com/toeverything/blocksuite/blob/9c2df3f7aa5617c050e0dccdd73e99bb67e0c0f7/packages/store/src/reactive/utils.ts#L143 + */ + false ); } + +// patch root doc's space guid compatibility issue +// +// in version 0.10, page id in spaces no longer has prefix "space:" +// The data flow for fetching a doc's updates is: +// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid` +// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId +// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces` +// - when fetching the rows of this doc using the doc id === page id, +// it will return empty since there is no updates associated with the page id +export function migrateGuidCompatibility(rootDoc: YDoc) { + const meta = rootDoc.getMap('meta') as YMap; + const pages = meta.get('pages') as YArray>; + pages?.forEach(page => { + const pageId = page.get('id') as string | undefined; + if (pageId?.includes(':')) { + // remove the prefix "space:" from page id + page.set('id', pageId.split(':').at(-1)); + } + }); + const spaces = rootDoc.getMap('spaces') as YMap; + spaces?.forEach((doc: YDoc, pageId: string) => { + if (pageId.includes(':')) { + const newPageId = pageId.split(':').at(-1) ?? pageId; + const newDoc = new YDoc(); + // clone the original doc. yjs is not happy to use the same doc instance + applyUpdate(newDoc, encodeStateAsUpdate(doc)); + newDoc.guid = doc.guid; + spaces.set(newPageId, newDoc); + // should remove the old doc, otherwise we will do it again in the next run + spaces.delete(pageId); + console.debug( + `fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}` + ); + } + }); +} diff --git a/packages/common/infra/src/blocksuite/migration/fixing.ts b/packages/common/infra/src/blocksuite/migration/fixing.ts index f46fd11c0b..5813da65da 100644 --- a/packages/common/infra/src/blocksuite/migration/fixing.ts +++ b/packages/common/infra/src/blocksuite/migration/fixing.ts @@ -1,48 +1,5 @@ -import type { Array as YArray, Map as YMap } from 'yjs'; -import { Doc as YDoc, transact } from 'yjs'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -// patch root doc's space guid compatibility issue -// -// in version 0.10, page id in spaces no longer has prefix "space:" -// The data flow for fetching a doc's updates is: -// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid` -// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId -// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces` -// - when fetching the rows of this doc using the doc id === page id, -// it will return empty since there is no updates associated with the page id -export function guidCompatibilityFix(rootDoc: YDoc) { - let changed = false; - transact(rootDoc, () => { - const meta = rootDoc.getMap('meta') as YMap; - const pages = meta.get('pages') as YArray>; - pages?.forEach(page => { - const pageId = page.get('id') as string | undefined; - if (pageId?.includes(':')) { - // remove the prefix "space:" from page id - page.set('id', pageId.split(':').at(-1)); - } - }); - const spaces = rootDoc.getMap('spaces') as YMap; - spaces?.forEach((doc: YDoc, pageId: string) => { - if (pageId.includes(':')) { - const newPageId = pageId.split(':').at(-1) ?? pageId; - const newDoc = new YDoc(); - // clone the original doc. yjs is not happy to use the same doc instance - applyUpdate(newDoc, encodeStateAsUpdate(doc)); - newDoc.guid = doc.guid; - spaces.set(newPageId, newDoc); - // should remove the old doc, otherwise we will do it again in the next run - spaces.delete(pageId); - changed = true; - console.debug( - `fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}` - ); - } - }); - }); - return changed; -} +import type { Doc as YDoc, Map as YMap } from 'yjs'; +import { transact } from 'yjs'; /** * Hard code to fix workspace version to be compatible with legacy data. @@ -55,13 +12,35 @@ export function fixWorkspaceVersion(rootDoc: YDoc) { * It doesn't matter to upgrade workspace version from 1 or undefined to 2. * Blocksuite just set the value, do nothing else. */ - const workspaceVersion = meta.get('workspaceVersion'); - if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { - meta.set('workspaceVersion', 2); - + function doFix() { + const workspaceVersion = meta.get('workspaceVersion'); + if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { + transact( + rootDoc, + () => { + meta.set('workspaceVersion', 2); + }, + 'fixWorkspaceVersion', + // transact as remote update, because blocksuite will skip local changes. + false + ); + } const pageVersion = meta.get('pageVersion'); - if (typeof pageVersion !== 'number') { - meta.set('pageVersion', 1); + if (typeof pageVersion !== 'number' || pageVersion < 2) { + transact( + rootDoc, + () => { + meta.set('pageVersion', 2); + }, + 'fixPageVersion', + // transact as remote update, because blocksuite will skip local changes. + false + ); } } + + doFix(); + + // do fix every time when meta changed + meta.observe(() => doFix()); } diff --git a/packages/common/infra/src/blocksuite/migration/subdoc.ts b/packages/common/infra/src/blocksuite/migration/subdoc.ts index da77756cc2..f45e610f86 100644 --- a/packages/common/infra/src/blocksuite/migration/subdoc.ts +++ b/packages/common/infra/src/blocksuite/migration/subdoc.ts @@ -1,4 +1,3 @@ -import type { Workspace } from '@blocksuite/store'; import { nanoid } from 'nanoid'; import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; @@ -264,19 +263,17 @@ export function migrateToSubdoc(oldDoc: YDoc): YDoc { return newDoc; } -export const upgradeV1ToV2 = async ( - oldDoc: YDoc, - createWorkspace: () => Promise -) => { +/** + * upgrade oldDoc to v2, write to targetDoc + */ +export const upgradeV1ToV2 = async (oldDoc: YDoc, targetDoc: YDoc) => { const newDoc = migrateToSubdoc(oldDoc); - const newWorkspace = await createWorkspace(); - applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin); + applyUpdate(targetDoc, encodeStateAsUpdate(newDoc), migrationOrigin); newDoc.getSubdocs().forEach(subdoc => { - newWorkspace.doc.getSubdocs().forEach(newDoc => { + targetDoc.getSubdocs().forEach(newDoc => { if (subdoc.guid === newDoc.guid) { applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin); } }); }); - return newWorkspace; }; diff --git a/packages/common/infra/src/blocksuite/migration/workspace.ts b/packages/common/infra/src/blocksuite/migration/workspace.ts index e26f644230..b2fa335d75 100644 --- a/packages/common/infra/src/blocksuite/migration/workspace.ts +++ b/packages/common/infra/src/blocksuite/migration/workspace.ts @@ -1,63 +1,50 @@ import type { Workspace } from '@blocksuite/store'; -import type { Schema } from '@blocksuite/store'; -import type { Doc as YDoc } from 'yjs'; - -import { migratePages } from './blocksuite'; -import { upgradeV1ToV2 } from './subdoc'; - -interface MigrationOptions { - doc: YDoc; - schema: Schema; - createWorkspace: () => Promise; -} - -function createMigrationQueue(options: MigrationOptions) { - return [ - async (doc: YDoc) => { - const newWorkspace = await upgradeV1ToV2(doc, options.createWorkspace); - return newWorkspace.doc; - }, - async (doc: YDoc) => { - await migratePages(doc, options.schema); - return doc; - }, - ]; -} +import type { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs'; /** * For split migrate function from MigrationQueue. */ export enum MigrationPoint { SubDoc = 1, - BlockVersion = 2, -} - -export async function migrateWorkspace( - point: MigrationPoint, - options: MigrationOptions -) { - const migrationQueue = createMigrationQueue(options); - const migrationFns = migrationQueue.slice(point - 1); - - let doc = options.doc; - for (const migrate of migrationFns) { - doc = await migrate(doc); - } - return doc; + GuidFix = 2, + BlockVersion = 3, } export function checkWorkspaceCompatibility( workspace: Workspace ): MigrationPoint | null { - const workspaceDocJSON = workspace.doc.toJSON(); - const spaceMetaObj = workspaceDocJSON['space:meta']; - const docKeys = Object.keys(workspaceDocJSON); - const haveSpaceMeta = !!spaceMetaObj && Object.keys(spaceMetaObj).length > 0; + // check if there is any key starts with 'space:' on root doc + const spaceMetaObj = workspace.doc.share.get('space:meta') as + | YMap + | undefined; + const docKeys = Array.from(workspace.doc.share.keys()); + const haveSpaceMeta = !!spaceMetaObj && spaceMetaObj.size > 0; const haveLegacySpace = docKeys.some(key => key.startsWith('space:')); if (haveSpaceMeta || haveLegacySpace) { return MigrationPoint.SubDoc; } + // exit if no pages + if (!workspace.meta.pages?.length) { + return null; + } + + // check guid compatibility + const meta = workspace.doc.getMap('meta') as YMap; + const pages = meta.get('pages') as YArray>; + for (const page of pages) { + const pageId = page.get('id') as string | undefined; + if (pageId?.includes(':')) { + return MigrationPoint.GuidFix; + } + } + const spaces = workspace.doc.getMap('spaces') as YMap; + for (const [pageId, _] of spaces) { + if (pageId.includes(':')) { + return MigrationPoint.GuidFix; + } + } + const hasVersion = workspace.meta.hasVersion; if (!hasVersion) { return MigrationPoint.BlockVersion; diff --git a/packages/common/infra/src/handler.ts b/packages/common/infra/src/handler.ts index 3be490d310..722fc13890 100644 --- a/packages/common/infra/src/handler.ts +++ b/packages/common/infra/src/handler.ts @@ -1,5 +1,6 @@ import type { ClipboardHandlers, + ConfigStorageHandlers, DBHandlers, DebugHandlers, DialogHandlers, @@ -49,3 +50,8 @@ export abstract class WorkspaceHandlerManager extends HandlerManager< 'workspace', WorkspaceHandlers > {} + +export abstract class ConfigStorageHandlerManager extends HandlerManager< + 'configStorage', + ConfigStorageHandlers +> {} diff --git a/packages/common/infra/src/preload/electron.ts b/packages/common/infra/src/preload/electron.ts index 25e398c75c..087141aaa5 100644 --- a/packages/common/infra/src/preload/electron.ts +++ b/packages/common/infra/src/preload/electron.ts @@ -59,6 +59,9 @@ schema = isDev ? 'affine-dev' : schema; export const appInfo = { electron: true, + windowName: process.argv + .find(arg => arg.startsWith('--window-name=')) + ?.split('=')[1], schema, }; diff --git a/packages/common/infra/src/type.ts b/packages/common/infra/src/type.ts index 732c8d1d4d..cdda8bfe28 100644 --- a/packages/common/infra/src/type.ts +++ b/packages/common/infra/src/type.ts @@ -3,6 +3,7 @@ import type Buffer from 'buffer'; import type { WritableAtom } from 'jotai'; import { z } from 'zod'; +import type { AppConfigSchema } from './app-config-storage.js'; import type { TypedEventEmitter } from './core/event-emitter.js'; type Buffer = Buffer.Buffer; @@ -175,6 +176,7 @@ export type UIHandlers = { handleCloseApp: () => Promise; getGoogleOauthCode: () => Promise; getChallengeResponse: (resource: string) => Promise; + handleOpenMainApp: () => Promise; }; export type ClipboardHandlers = { @@ -211,6 +213,11 @@ export type WorkspaceHandlers = { clone: (id: string, newId: string) => Promise; }; +export type ConfigStorageHandlers = { + set: (config: AppConfigSchema | Partial) => Promise; + get: () => Promise; +}; + export type UnwrapManagerHandlerToServerSide< ElectronEvent extends { frameId: number; diff --git a/packages/common/infra/vite.config.ts b/packages/common/infra/vite.config.ts index efd6f41b66..03baf6f2a7 100644 --- a/packages/common/infra/vite.config.ts +++ b/packages/common/infra/vite.config.ts @@ -18,10 +18,7 @@ export default defineConfig({ type: resolve(root, 'src/type.ts'), 'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'), 'preload/electron': resolve(root, 'src/preload/electron.ts'), - '__internal__/workspace': resolve( - root, - 'src/__internal__/workspace.ts' - ), + 'app-config-storage': resolve(root, 'src/app-config-storage.ts'), '__internal__/plugin': resolve(root, 'src/__internal__/plugin.ts'), }, formats: ['es', 'cjs'], diff --git a/packages/common/sdk/package.json b/packages/common/sdk/package.json index 003c7ecdf0..4daeef9900 100644 --- a/packages/common/sdk/package.json +++ b/packages/common/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@affine/sdk", - "version": "0.10.3", + "version": "0.11.0", "type": "module", "scripts": { "build": "vite build", @@ -22,11 +22,11 @@ "dist" ], "dependencies": { - "@blocksuite/block-std": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/blocks": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/global": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/presets": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/store": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/block-std": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/blocks": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/global": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/presets": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/store": "0.11.0-nightly-202312200102-8254dc9", "jotai": "^2.5.1", "zod": "^3.22.4" }, diff --git a/packages/common/sdk/src/entry.ts b/packages/common/sdk/src/entry.ts index 2e4025a229..7421ebeb1e 100644 --- a/packages/common/sdk/src/entry.ts +++ b/packages/common/sdk/src/entry.ts @@ -1,5 +1,5 @@ import type { BaseSelection } from '@blocksuite/block-std'; -import type { EditorContainer } from '@blocksuite/presets'; +import type { AffineEditorContainer } from '@blocksuite/presets'; import type { Page } from '@blocksuite/store'; import type { Workspace } from '@blocksuite/store'; import type { Atom, getDefaultStore } from 'jotai/vanilla'; @@ -10,7 +10,7 @@ export type Part = 'headerItem' | 'editor' | 'setting' | 'formatBar'; export type CallbackMap = { headerItem: (root: HTMLElement) => () => void; - editor: (root: HTMLElement, editor: EditorContainer) => () => void; + editor: (root: HTMLElement, editor: AffineEditorContainer) => () => void; setting: (root: HTMLElement) => () => void; formatBar: ( root: HTMLElement, diff --git a/packages/common/y-indexeddb/package.json b/packages/common/y-indexeddb/package.json index 404610475d..562c84069a 100644 --- a/packages/common/y-indexeddb/package.json +++ b/packages/common/y-indexeddb/package.json @@ -1,7 +1,7 @@ { "name": "@toeverything/y-indexeddb", "type": "module", - "version": "0.10.3", + "version": "0.11.0", "description": "IndexedDB database adapter for Yjs", "repository": "toeverything/AFFiNE", "author": "toeverything", @@ -37,12 +37,12 @@ "y-provider": "workspace:*" }, "devDependencies": { - "@blocksuite/blocks": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/store": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/blocks": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/store": "0.11.0-nightly-202312200102-8254dc9", "fake-indexeddb": "^5.0.0", "vite": "^5.0.6", "vite-plugin-dts": "3.6.0", - "vitest": "0.34.6", + "vitest": "1.0.4", "y-indexeddb": "^9.0.11", "yjs": "^13.6.10" }, diff --git a/packages/common/y-provider/package.json b/packages/common/y-provider/package.json index 03f5d8e575..ee18dfa3c3 100644 --- a/packages/common/y-provider/package.json +++ b/packages/common/y-provider/package.json @@ -1,7 +1,7 @@ { "name": "y-provider", "type": "module", - "version": "0.10.3", + "version": "0.11.0", "description": "Yjs provider protocol for multi document support", "exports": { ".": "./src/index.ts" @@ -24,10 +24,10 @@ "build": "vite build" }, "devDependencies": { - "@blocksuite/store": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/store": "0.11.0-nightly-202312200102-8254dc9", "vite": "^5.0.6", "vite-plugin-dts": "3.6.0", - "vitest": "0.34.6", + "vitest": "1.0.4", "yjs": "^13.6.10" }, "peerDependencies": { diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 0ed194404e..d36d062523 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -68,15 +68,16 @@ "react-router-dom": "^6.16.0", "react-virtuoso": "^4.6.2", "rxjs": "^7.8.1", + "swr": "^2.2.4", "uuid": "^9.0.1" }, "devDependencies": { - "@blocksuite/blocks": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/global": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/blocks": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/global": "0.11.0-nightly-202312200102-8254dc9", "@blocksuite/icons": "2.1.36", - "@blocksuite/lit": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/presets": "0.11.0-nightly-202312120309-2b3d2ba", - "@blocksuite/store": "0.11.0-nightly-202312120309-2b3d2ba", + "@blocksuite/lit": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/presets": "0.11.0-nightly-202312200102-8254dc9", + "@blocksuite/store": "0.11.0-nightly-202312200102-8254dc9", "@storybook/addon-actions": "^7.5.3", "@storybook/addon-essentials": "^7.5.3", "@storybook/addon-interactions": "^7.5.3", @@ -102,8 +103,8 @@ "storybook-dark-mode": "^3.0.1", "typescript": "^5.3.2", "vite": "^5.0.6", - "vitest": "0.34.6", + "vitest": "1.0.4", "yjs": "^13.6.10" }, - "version": "0.10.3" + "version": "0.11.0" } diff --git a/packages/frontend/component/src/components/app-sidebar/add-page-button/index.tsx b/packages/frontend/component/src/components/app-sidebar/add-page-button/index.tsx index 43bde9f114..405e111d4b 100644 --- a/packages/frontend/component/src/components/app-sidebar/add-page-button/index.tsx +++ b/packages/frontend/component/src/components/app-sidebar/add-page-button/index.tsx @@ -21,7 +21,7 @@ export function AddPageButton({ return ( + + + + + ); + } + return ( + +
+

Thank you!

+

+ We will continue to enhance our products based on your feedback. Thank + you once again for your supports. +

+ +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/block-suite-editor/index.jotai.ts b/packages/frontend/component/src/components/block-suite-editor/index.jotai.ts new file mode 100644 index 0000000000..8f3af298e4 --- /dev/null +++ b/packages/frontend/component/src/components/block-suite-editor/index.jotai.ts @@ -0,0 +1,4 @@ +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { atom } from 'jotai'; + +export const editorContainerAtom = atom(null); diff --git a/packages/frontend/component/src/components/block-suite-editor/index.tsx b/packages/frontend/component/src/components/block-suite-editor/index.tsx index e48dcdc89a..43bec257af 100644 --- a/packages/frontend/component/src/components/block-suite-editor/index.tsx +++ b/packages/frontend/component/src/components/block-suite-editor/index.tsx @@ -1,8 +1,9 @@ import { assertExists } from '@blocksuite/global/utils'; -import { EditorContainer } from '@blocksuite/presets'; +import { AffineEditorContainer } from '@blocksuite/presets'; import type { Page } from '@blocksuite/store'; import clsx from 'clsx'; import { use } from 'foxact/use'; +import { useAtom } from 'jotai'; import type { CSSProperties, ReactElement } from 'react'; import { memo, @@ -19,8 +20,11 @@ import { blockSuiteEditorHeaderStyle, blockSuiteEditorStyle, } from './index.css'; +import { editorContainerAtom } from './index.jotai'; import { editorSpecs } from './specs'; +export { editorContainerAtom } from './index.jotai'; + interface BlockElement extends Element { path: string[]; } @@ -31,7 +35,7 @@ export type EditorProps = { defaultSelectedBlockId?: string; onModeChange?: (mode: 'page' | 'edgeless') => void; // on Editor instance instantiated - onLoadEditor?: (editor: EditorContainer) => () => void; + onLoadEditor?: (editor: AffineEditorContainer) => () => void; style?: CSSProperties; className?: string; }; @@ -147,10 +151,11 @@ const BlockSuiteEditorImpl = ({ }: EditorProps): ReactElement => { usePageRoot(page); + const [, setEditorContainer] = useAtom(editorContainerAtom); assertExists(page, 'page should not be null'); - const editorRef = useRef(null); + const editorRef = useRef(null); if (editorRef.current === null) { - editorRef.current = new EditorContainer(); + editorRef.current = new AffineEditorContainer(); editorRef.current.autofocus = true; } const editor = editorRef.current; @@ -193,10 +198,11 @@ const BlockSuiteEditorImpl = ({ return; } container.append(editor); + setEditorContainer(editor); return () => { editor.remove(); }; - }, [editor]); + }, [editor, setEditorContainer]); const blockElement = useBlockElementById( containerRef.current, diff --git a/packages/frontend/component/src/components/card/workspace-card/index.tsx b/packages/frontend/component/src/components/card/workspace-card/index.tsx index a8d6049a9c..84a0287819 100644 --- a/packages/frontend/component/src/components/card/workspace-card/index.tsx +++ b/packages/frontend/component/src/components/card/workspace-card/index.tsx @@ -1,11 +1,10 @@ +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace'; import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons'; -import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url'; -import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; -import { useAtomValue } from 'jotai/react'; +import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob'; +import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info'; import { useCallback } from 'react'; import { Avatar } from '../../../ui/avatar'; @@ -68,10 +67,10 @@ const WorkspaceType = ({ flavour, isOwner }: WorkspaceTypeProps) => { }; export interface WorkspaceCardProps { - currentWorkspaceId: string | null; - meta: RootWorkspaceMetadata; - onClick: (workspaceId: string) => void; - onSettingClick: (workspaceId: string) => void; + currentWorkspaceId?: string | null; + meta: WorkspaceMetadata; + onClick: (metadata: WorkspaceMetadata) => void; + onSettingClick: (metadata: WorkspaceMetadata) => void; isOwner?: boolean; } @@ -98,29 +97,31 @@ export const WorkspaceCard = ({ meta, isOwner = true, }: WorkspaceCardProps) => { - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(meta.id); - const workspace = useAtomValue(workspaceAtom); - const [name] = useBlockSuiteWorkspaceName(workspace); - const [workspaceAvatar] = useBlockSuiteWorkspaceAvatarUrl(workspace); + const information = useWorkspaceInfo(meta); + const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar); + + const name = information?.name ?? UNTITLED_WORKSPACE_NAME; return ( { - onClick(meta.id); - }, [onClick, meta.id])} - active={workspace.id === currentWorkspaceId} + onClick(meta); + }, [onClick, meta])} + active={meta.id === currentWorkspaceId} > - + - {name} + + {information?.name ?? UNTITLED_WORKSPACE_NAME} + { e.stopPropagation(); - onSettingClick(meta.id); + onSettingClick(meta); }} withoutHoverStyle={true} > diff --git a/packages/frontend/component/src/components/member-components/invite-modal.tsx b/packages/frontend/component/src/components/member-components/invite-modal.tsx index 920671319b..270484a0d8 100644 --- a/packages/frontend/component/src/components/member-components/invite-modal.tsx +++ b/packages/frontend/component/src/components/member-components/invite-modal.tsx @@ -74,9 +74,6 @@ export const InviteModal = ({ error={!isValidEmail} errorHint={isValidEmail ? '' : t['com.affine.auth.sign.email.error']()} onEnter={handleConfirm} - wrapperProps={{ - style: { padding: 0 }, - }} size="large" /> diff --git a/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index 65009d631c..fe0c5d44b8 100644 --- a/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -12,7 +12,7 @@ import { expect, test } from 'vitest'; import { createDefaultFilter, vars } from '../filter/vars'; import { - type CollectionsCRUDAtom, + type CollectionsCRUD, useCollectionManager, } from '../use-collection-manager'; @@ -27,18 +27,18 @@ const baseAtom = atomWithObservable( } ); -const mockAtom: CollectionsCRUDAtom = atom(get => { +const mockAtom = atom(get => { return { collections: get(baseAtom), - addCollection: async (...collections) => { + addCollection: (...collections) => { const prev = collectionsSubject.value; collectionsSubject.next([...collections, ...prev]); }, - deleteCollection: async (...ids) => { + deleteCollection: (...ids) => { const prev = collectionsSubject.value; collectionsSubject.next(prev.filter(v => !ids.includes(v.id))); }, - updateCollection: async (id, updater) => { + updateCollection: (id, updater) => { const prev = collectionsSubject.value; collectionsSubject.next( prev.map(v => { @@ -49,14 +49,14 @@ const mockAtom: CollectionsCRUDAtom = atom(get => { }) ); }, - }; + } satisfies CollectionsCRUD; }); test('useAllPageSetting', async () => { const settingHook = renderHook(() => useCollectionManager(mockAtom)); const prevCollection = settingHook.result.current.currentCollection; expect(settingHook.result.current.savedCollections).toEqual([]); - await settingHook.result.current.updateCollection({ + settingHook.result.current.updateCollection({ ...settingHook.result.current.currentCollection, filterList: [createDefaultFilter(vars[0], defaultMeta)], }); @@ -66,7 +66,7 @@ test('useAllPageSetting', async () => { expect(nextCollection.filterList).toEqual([ createDefaultFilter(vars[0], defaultMeta), ]); - await settingHook.result.current.createCollection({ + settingHook.result.current.createCollection({ ...settingHook.result.current.currentCollection, id: '1', }); diff --git a/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx b/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx index 5267b25b26..8cdc0bb2ad 100644 --- a/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx +++ b/packages/frontend/component/src/components/page-list/components/new-page-buttton.tsx @@ -95,7 +95,6 @@ export const NewPageButton = ({ > { createNewPage(); setOpen(false); diff --git a/packages/frontend/component/src/components/page-list/page-list-item.css.ts b/packages/frontend/component/src/components/page-list/page-list-item.css.ts index 2fee2391d4..82cd0b6572 100644 --- a/packages/frontend/component/src/components/page-list/page-list-item.css.ts +++ b/packages/frontend/component/src/components/page-list/page-list-item.css.ts @@ -23,13 +23,9 @@ export const root = style({ export const dragOverlay = style({ display: 'flex', - height: '54px', // 42 + 12 alignItems: 'center', - background: 'var(--affine-hover-color-filled)', - boxShadow: 'var(--affine-menu-shadow)', - borderRadius: 10, zIndex: 1001, - cursor: 'pointer', + cursor: 'grabbing', maxWidth: '360px', transition: 'transform 0.2s', willChange: 'transform', @@ -39,6 +35,16 @@ export const dragOverlay = style({ }, }, }); +export const dragPageItemOverlay = style({ + height: '54px', + borderRadius: '10px', + display: 'flex', + alignItems: 'center', + background: 'var(--affine-hover-color-filled)', + boxShadow: 'var(--affine-menu-shadow)', + maxWidth: '360px', + minWidth: '260px', +}); export const dndCell = style({ position: 'relative', diff --git a/packages/frontend/component/src/components/page-list/page-list-item.tsx b/packages/frontend/component/src/components/page-list/page-list-item.tsx index d5f0babc49..ce55e32ac7 100644 --- a/packages/frontend/component/src/components/page-list/page-list-item.tsx +++ b/packages/frontend/component/src/components/page-list/page-list-item.tsx @@ -121,7 +121,7 @@ const PageListOperationsCell = ({ export const PageListItem = (props: PageListItemProps) => { const pageTitleElement = useMemo(() => { return ( - <> +
{
- +
); }, [ props.icon, @@ -142,6 +142,7 @@ export const PageListItem = (props: PageListItemProps) => { props.title, ]); + // TODO: use getDropItemId const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: 'page-list-item-title-' + props.pageId, data: { diff --git a/packages/frontend/component/src/components/page-list/use-collection-manager.ts b/packages/frontend/component/src/components/page-list/use-collection-manager.ts index 4e3cb13273..9d501d6b82 100644 --- a/packages/frontend/component/src/components/page-list/use-collection-manager.ts +++ b/packages/frontend/component/src/components/page-list/use-collection-manager.ts @@ -33,22 +33,21 @@ export const currentCollectionAtom = atomWithReset(NIL); export type Updater = (value: T) => T; export type CollectionUpdater = Updater; export type CollectionsCRUD = { - addCollection: (...collections: Collection[]) => Promise; + addCollection: (...collections: Collection[]) => void; collections: Collection[]; - updateCollection: (id: string, updater: CollectionUpdater) => Promise; - deleteCollection: ( - info: DeleteCollectionInfo, - ...ids: string[] - ) => Promise; + updateCollection: (id: string, updater: CollectionUpdater) => void; + deleteCollection: (info: DeleteCollectionInfo, ...ids: string[]) => void; }; -export type CollectionsCRUDAtom = Atom; +export type CollectionsCRUDAtom = Atom< + Promise | CollectionsCRUD +>; export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => { const [{ collections, addCollection, deleteCollection, updateCollection }] = useAtom(collectionAtom); const addPage = useCallback( - async (collectionId: string, pageId: string) => { - await updateCollection(collectionId, old => { + (collectionId: string, pageId: string) => { + updateCollection(collectionId, old => { return { ...old, allowList: [pageId, ...(old.allowList ?? [])], @@ -79,11 +78,11 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { defaultCollectionAtom ); const update = useCallback( - async (collection: Collection) => { + (collection: Collection) => { if (collection.id === NIL) { updateDefaultCollection(collection); } else { - await updateCollection(collection.id, () => collection); + updateCollection(collection.id, () => collection); } }, [updateDefaultCollection, updateCollection] @@ -102,6 +101,7 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { ? defaultCollection : collections.find(v => v.id === currentCollectionId) ?? defaultCollection; + return { currentCollection: currentCollection, savedCollections: collections, diff --git a/packages/frontend/component/src/components/page-list/view/collection-list.tsx b/packages/frontend/component/src/components/page-list/view/collection-list.tsx index 80a31205b1..2bede93aa0 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-list.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-list.tsx @@ -35,14 +35,10 @@ export const CollectionList = ({ const [collection, setCollection] = useState(); const onChange = useCallback( (filterList: Filter[]) => { - setting - .updateCollection({ - ...setting.currentCollection, - filterList, - }) - .catch(err => { - console.error(err); - }); + setting.updateCollection({ + ...setting.currentCollection, + filterList, + }); }, [setting] ); @@ -53,8 +49,8 @@ export const CollectionList = ({ }, []); const onConfirm = useCallback( - async (view: Collection) => { - await setting.updateCollection(view); + (view: Collection) => { + setting.updateCollection(view); closeUpdateCollectionModal(false); }, [closeUpdateCollectionModal, setting] diff --git a/packages/frontend/component/src/components/page-list/view/collection-operations.tsx b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx index fc2564c812..97cac902ef 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx @@ -22,12 +22,14 @@ export const CollectionOperations = ({ config, setting, info, + openRenameModal, children, }: PropsWithChildren<{ info: DeleteCollectionInfo; collection: Collection; config: AllPageListConfig; setting: ReturnType; + openRenameModal?: () => void; }>) => { const { open: openEditCollectionModal, node: editModal } = useEditCollection(config); @@ -36,7 +38,12 @@ export const CollectionOperations = ({ useEditCollectionName({ title: t['com.affine.editCollection.renameCollection'](), }); + const showEditName = useCallback(() => { + // use openRenameModal if it is in the sidebar collection list + if (openRenameModal) { + return openRenameModal(); + } openEditCollectionNameModal(collection.name) .then(name => { return setting.updateCollection({ ...collection, name }); @@ -44,7 +51,8 @@ export const CollectionOperations = ({ .catch(err => { console.error(err); }); - }, [openEditCollectionNameModal, collection, setting]); + }, [openRenameModal, openEditCollectionNameModal, collection, setting]); + const showEdit = useCallback(() => { openEditCollectionModal(collection) .then(collection => { @@ -54,6 +62,7 @@ export const CollectionOperations = ({ console.error(err); }); }, [setting, collection, openEditCollectionModal]); + const actions = useMemo< Array< | { @@ -98,9 +107,7 @@ export const CollectionOperations = ({ ), name: t['Delete'](), click: () => { - setting.deleteCollection(info, collection.id).catch(err => { - console.error(err); - }); + setting.deleteCollection(info, collection.id); }, type: 'danger', }, diff --git a/packages/frontend/component/src/components/page-list/view/create-collection.tsx b/packages/frontend/component/src/components/page-list/view/create-collection.tsx index 086c449e8c..5046eaeda1 100644 --- a/packages/frontend/component/src/components/page-list/view/create-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/create-collection.tsx @@ -10,7 +10,7 @@ export interface CreateCollectionModalProps { title?: string; onConfirmText?: string; init: string; - onConfirm: (title: string) => Promise; + onConfirm: (title: string) => void; open: boolean; showTips?: boolean; onOpenChange: (open: boolean) => void; @@ -27,13 +27,8 @@ export const CreateCollectionModal = ({ const t = useAFFiNEI18N(); const onConfirmTitle = useCallback( (title: string) => { - onConfirm(title) - .then(() => { - onOpenChange(false); - }) - .catch(err => { - console.error(err); - }); + onConfirm(title); + onOpenChange(false); }, [onConfirm, onOpenChange] ); diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx index f789467b83..b96a0ad448 100644 --- a/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx @@ -19,7 +19,7 @@ export interface EditCollectionModalProps { open: boolean; mode?: EditCollectionMode; onOpenChange: (open: boolean) => void; - onConfirm: (view: Collection) => Promise; + onConfirm: (view: Collection) => void; allPageListConfig: AllPageListConfig; } @@ -45,13 +45,8 @@ export const EditCollectionModal = ({ const t = useAFFiNEI18N(); const onConfirmOnCollection = useCallback( (view: Collection) => { - onConfirm(view) - .then(() => { - onOpenChange(false); - }) - .catch(err => { - console.error(err); - }); + onConfirm(view); + onOpenChange(false); }, [onConfirm, onOpenChange] ); diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection/hooks.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection/hooks.tsx index b21106f0fc..538edffb4b 100644 --- a/packages/frontend/component/src/components/page-list/view/edit-collection/hooks.tsx +++ b/packages/frontend/component/src/components/page-list/view/edit-collection/hooks.tsx @@ -27,7 +27,7 @@ export const useSelectPage = ({ contentOptions={{ style: { padding: 0, - transform: 'translate(-50%,calc(-50% + 16px))', + transform: 'translateY(16px)', maxWidth: 976, backgroundColor: 'var(--affine-white)', }, diff --git a/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx b/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx index 98e7fb4234..eec63d4e4f 100644 --- a/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx +++ b/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx @@ -9,7 +9,7 @@ import { createEmptyCollection } from '../use-collection-manager'; import { useEditCollectionName } from './use-edit-collection'; interface SaveAsCollectionButtonProps { - onConfirm: (collection: Collection) => Promise; + onConfirm: (collection: Collection) => void; } export const SaveAsCollectionButton = ({ diff --git a/packages/frontend/component/src/components/page-list/view/use-action.tsx b/packages/frontend/component/src/components/page-list/view/use-action.tsx index 14a104cc39..438c92a51b 100644 --- a/packages/frontend/component/src/components/page-list/view/use-action.tsx +++ b/packages/frontend/component/src/components/page-list/view/use-action.tsx @@ -40,9 +40,7 @@ export const useActions = ({ name: 'delete', tooltip: t['com.affine.collection-bar.action.tooltip.delete'](), click: () => { - setting.deleteCollection(info, collection.id).catch(err => { - console.error(err); - }); + setting.deleteCollection(info, collection.id); }, }, ]; diff --git a/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx index 063e5cf19a..bcf5b07f9f 100644 --- a/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx @@ -12,7 +12,7 @@ export const useEditCollection = (config: AllPageListConfig) => { const [data, setData] = useState<{ collection: Collection; mode?: 'page' | 'rule'; - onConfirm: (collection: Collection) => Promise; + onConfirm: (collection: Collection) => void; }>(); const close = useCallback(() => setData(undefined), []); @@ -35,7 +35,7 @@ export const useEditCollection = (config: AllPageListConfig) => { setData({ collection, mode, - onConfirm: async collection => { + onConfirm: collection => { res(collection); }, }); @@ -52,7 +52,7 @@ export const useEditCollectionName = ({ }) => { const [data, setData] = useState<{ name: string; - onConfirm: (name: string) => Promise; + onConfirm: (name: string) => void; }>(); const close = useCallback(() => setData(undefined), []); @@ -71,7 +71,7 @@ export const useEditCollectionName = ({ new Promise(res => { setData({ name, - onConfirm: async collection => { + onConfirm: collection => { res(collection); }, }); diff --git a/packages/frontend/component/src/components/rename-modal/index.css.ts b/packages/frontend/component/src/components/rename-modal/index.css.ts new file mode 100644 index 0000000000..c62659e122 --- /dev/null +++ b/packages/frontend/component/src/components/rename-modal/index.css.ts @@ -0,0 +1,6 @@ +import { style } from '@vanilla-extract/css'; + +export const input = style({ + height: '34px', + width: '220px', +}); diff --git a/packages/frontend/component/src/components/rename-modal/index.tsx b/packages/frontend/component/src/components/rename-modal/index.tsx new file mode 100644 index 0000000000..453d4e3649 --- /dev/null +++ b/packages/frontend/component/src/components/rename-modal/index.tsx @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; + +import Input from '../../ui/input'; +import { Menu } from '../../ui/menu'; +import * as styles from './index.css'; + +export const RenameModal = ({ + onRename, + currentName, + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onRename: (newName: string) => void; + currentName: string; +}) => { + const [value, setValue] = useState(currentName); + const handleRename = useCallback(() => { + onRename(value); + onOpenChange(false); + }, [onOpenChange, onRename, value]); + return ( + + } + > +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/resize-panel/index.tsx b/packages/frontend/component/src/components/resize-panel/index.tsx new file mode 100644 index 0000000000..8593374e27 --- /dev/null +++ b/packages/frontend/component/src/components/resize-panel/index.tsx @@ -0,0 +1 @@ +export * from './resize-panel'; diff --git a/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts b/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts new file mode 100644 index 0000000000..2fcbd37a01 --- /dev/null +++ b/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts @@ -0,0 +1,100 @@ +import { createVar, style } from '@vanilla-extract/css'; + +export const panelWidthVar = createVar('panel-width'); +export const resizeHandleOffsetVar = createVar('resize-handle-offset'); +export const resizeHandleVerticalPadding = createVar( + 'resize-handle-vertical-padding' +); + +export const root = style({ + vars: { + [panelWidthVar]: '256px', + [resizeHandleOffsetVar]: '0', + }, + position: 'relative', + width: panelWidthVar, + minWidth: 0, + height: '100%', + selectors: { + '&[data-is-floating="true"]': { + position: 'absolute', + width: `calc(${panelWidthVar})`, + zIndex: 4, + }, + '&[data-open="true"]': { + maxWidth: '50%', + }, + '&[data-open="false"][data-handle-position="right"]': { + marginLeft: `calc(${panelWidthVar} * -1)`, + }, + '&[data-open="false"][data-handle-position="left"]': { + marginRight: `calc(${panelWidthVar} * -1)`, + }, + '&[data-enable-animation="true"]': { + transition: 'margin-left .3s .05s, margin-right .3s .05s, width .3s .05s', + }, + '&[data-is-floating="false"][data-transparent=true]': { + backgroundColor: 'transparent', + }, + }, +}); + +export const panelContent = style({ + position: 'relative', + height: '100%', + overflow: 'auto', +}); + +export const resizeHandleContainer = style({ + position: 'absolute', + right: resizeHandleOffsetVar, + top: resizeHandleVerticalPadding, + bottom: resizeHandleVerticalPadding, + width: 16, + zIndex: '1', + transform: 'translateX(50%)', + backgroundColor: 'transparent', + opacity: 0, + display: 'flex', + justifyContent: 'center', + cursor: 'ew-resize', + '@media': { + '(max-width: 600px)': { + // do not allow resizing on small screen + display: 'none', + }, + }, + transition: 'opacity 0.15s ease 0.1s', + selectors: { + '&[data-resizing="true"], &:hover': { + opacity: 1, + }, + '&[data-open="false"]': { + display: 'none', + }, + '&[data-open="open"]': { + display: 'block', + }, + '&[data-handle-position="left"]': { + left: resizeHandleOffsetVar, + right: 'auto', + transform: 'translateX(-50%)', + }, + }, +}); + +export const resizerInner = style({ + position: 'absolute', + height: '100%', + width: '2px', + borderRadius: '2px', + backgroundColor: 'var(--affine-primary-color)', + transition: 'all 0.2s ease-in-out', + transform: 'translateX(0.5px)', + selectors: { + [`${resizeHandleContainer}[data-resizing="true"] &`]: { + width: '4px', + borderRadius: '4px', + }, + }, +}); diff --git a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx new file mode 100644 index 0000000000..42584bfc82 --- /dev/null +++ b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx @@ -0,0 +1,182 @@ +import { assertExists } from '@blocksuite/global/utils'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; + +import * as styles from './resize-panel.css'; + +export interface ResizeHandleProps + extends React.HtmlHTMLAttributes { + resizing: boolean; + open: boolean; + minWidth: number; + maxWidth: number; + resizeHandlePos: 'left' | 'right'; + resizeHandleOffset?: number; + resizeHandleVerticalPadding?: number; + onOpen: (open: boolean) => void; + onResizing: (resizing: boolean) => void; + onWidthChange: (width: number) => void; +} + +export interface ResizePanelProps + extends React.HtmlHTMLAttributes { + resizing: boolean; + open: boolean; + floating?: boolean; + minWidth: number; + maxWidth: number; + resizeHandlePos: 'left' | 'right'; + resizeHandleOffset?: number; + resizeHandleVerticalPadding?: number; + enableAnimation?: boolean; + width: number; + onOpen: (open: boolean) => void; + onResizing: (resizing: boolean) => void; + onWidthChange: (width: number) => void; +} + +const ResizeHandle = ({ + className, + resizing, + minWidth, + maxWidth, + resizeHandlePos, + resizeHandleOffset, + resizeHandleVerticalPadding, + open, + onOpen, + onResizing, + onWidthChange, + ...rest +}: ResizeHandleProps) => { + const ref = useRef(null); + const onResizeStart = useCallback(() => { + let resized = false; + const panelContainer = ref.current?.parentElement; + assertExists( + panelContainer, + 'parent element not found for resize indicator' + ); + + const { left: anchorLeft, right: anchorRight } = + panelContainer.getBoundingClientRect(); + + function onMouseMove(e: MouseEvent) { + e.preventDefault(); + if (!panelContainer) return; + const newWidth = Math.min( + maxWidth, + Math.max( + resizeHandlePos === 'right' + ? e.clientX - anchorLeft + : anchorRight - e.clientX, + minWidth + ) + ); + onWidthChange(newWidth); + onResizing(true); + resized = true; + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener( + 'mouseup', + () => { + // if not resized, toggle sidebar + if (!resized) { + onOpen(false); + } + onResizing(false); + document.removeEventListener('mousemove', onMouseMove); + }, + { once: true } + ); + }, [maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen]); + + return ( +
+
+
+ ); +}; + +function useEnableAnimation() { + const [enable, setEnable] = useState(false); + useEffect(() => { + window.setTimeout(() => { + setEnable(true); + }, 500); + }, []); + return enable; +} + +export const ResizePanel = forwardRef( + function ResizePanel( + { + children, + className, + resizing, + minWidth, + maxWidth, + width, + floating, + enableAnimation: _enableAnimation = true, + open, + onOpen, + onResizing, + onWidthChange, + resizeHandlePos, + resizeHandleOffset, + resizeHandleVerticalPadding, + ...rest + }, + ref + ) { + const enableAnimation = useEnableAnimation() && _enableAnimation; + const safeWidth = Math.min(maxWidth, Math.max(minWidth, width)); + return ( +
+ {children} + +
+ ); + } +); diff --git a/packages/frontend/component/src/components/workspace-list/index.tsx b/packages/frontend/component/src/components/workspace-list/index.tsx index 5557616370..e55f08b78e 100644 --- a/packages/frontend/component/src/components/workspace-list/index.tsx +++ b/packages/frontend/component/src/components/workspace-list/index.tsx @@ -1,8 +1,4 @@ -import type { - AffineCloudWorkspace, - LocalWorkspace, -} from '@affine/env/workspace'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace'; import type { DragEndEvent } from '@dnd-kit/core'; import { DndContext, @@ -26,17 +22,17 @@ import { workspaceItemStyle } from './index.css'; export interface WorkspaceListProps { disabled?: boolean; - currentWorkspaceId: string | null; - items: (AffineCloudWorkspace | LocalWorkspace)[]; - onClick: (workspaceId: string) => void; - onSettingClick: (workspaceId: string) => void; + currentWorkspaceId?: string | null; + items: WorkspaceMetadata[]; + onClick: (workspaceMetadata: WorkspaceMetadata) => void; + onSettingClick: (workspaceMetadata: WorkspaceMetadata) => void; onDragEnd: (event: DragEndEvent) => void; - useIsWorkspaceOwner?: (workspaceId: string) => boolean; + useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean; } interface SortableWorkspaceItemProps extends Omit { - item: RootWorkspaceMetadata; - useIsWorkspaceOwner?: (workspaceId: string) => boolean; + item: WorkspaceMetadata; + useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean; } const SortableWorkspaceItem = ({ @@ -62,7 +58,7 @@ const SortableWorkspaceItem = ({ }), [disabled, transform, transition] ); - const isOwner = useIsWorkspaceOwner?.(item.id); + const isOwner = useIsWorkspaceOwner?.(item); return (