mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
Compare commits
67 Commits
v0.10.4-be
...
v0.11.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55792e2f41 | ||
|
|
04e7a9fc14 | ||
|
|
fcc3e9e069 | ||
|
|
9981c24120 | ||
|
|
a4f31df192 | ||
|
|
80eeb2ddc7 | ||
|
|
800ea0abf1 | ||
|
|
e3882f9648 | ||
|
|
30e62bd2c6 | ||
|
|
33a589a8ba | ||
|
|
8ea910a2bb | ||
|
|
31b1b2dade | ||
|
|
36653e79d2 | ||
|
|
197d1d4136 | ||
|
|
07f10f55bf | ||
|
|
6ca725343a | ||
|
|
d03567f689 | ||
|
|
128f8066c3 | ||
|
|
e10609276d | ||
|
|
b9345e8d21 | ||
|
|
55818539af | ||
|
|
4b0ca06d80 | ||
|
|
38617abc17 | ||
|
|
d9f1cc60b9 | ||
|
|
841385666e | ||
|
|
15dd20ef48 | ||
|
|
e0d328676d | ||
|
|
6748e7ba42 | ||
|
|
a815fd6b9a | ||
|
|
408b84109b | ||
|
|
c7fe42a5b9 | ||
|
|
cef9e0539d | ||
|
|
a1c9ac80d8 | ||
|
|
1b5837e545 | ||
|
|
a3d4c5c709 | ||
|
|
fc56a53acd | ||
|
|
fe2851d3e9 | ||
|
|
af15aa06d4 | ||
|
|
136b4ccb4e | ||
|
|
e9dfa93b52 | ||
|
|
0c2d2f8d16 | ||
|
|
2b7f6f8b74 | ||
|
|
a93c12e122 | ||
|
|
ad23ead5e4 | ||
|
|
63de73a815 | ||
|
|
c66781970b | ||
|
|
b925731bf7 | ||
|
|
3efcdc0cc5 | ||
|
|
0dc9358972 | ||
|
|
8aac1e09e2 | ||
|
|
77a5552dcd | ||
|
|
098787bd0c | ||
|
|
cd2efb4f0b | ||
|
|
ce64685176 | ||
|
|
2a9a6855f4 | ||
|
|
ad2c254ca3 | ||
|
|
e4369c7f0b | ||
|
|
883ab46557 | ||
|
|
7d32ddf539 | ||
|
|
31dc1f5e00 | ||
|
|
c9f900b69c | ||
|
|
738302be40 | ||
|
|
797cd5c6eb | ||
|
|
f4a52c031f | ||
|
|
b782b3fb1b | ||
|
|
9aa33d0228 | ||
|
|
bf97a07d1f |
25
.github/actions/deploy/deploy.mjs
vendored
25
.github/actions/deploy/deploy.mjs
vendored
@@ -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,
|
||||
|
||||
20
.github/actions/setup-version/action.yml
vendored
Normal file
20
.github/actions/setup-version/action.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Setup Version
|
||||
description: 'Setup Version'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: 'Write Version'
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.ref_type }}" == "tag" ]; then
|
||||
APP_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
else
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
TIME_VERSION=$(date +%Y%m%d%H%M)
|
||||
GIT_SHORT_HASH=$(git rev-parse --short HEAD)
|
||||
APP_VERSION=$PACKAGE_VERSION-nightly-$TIME_VERSION-$GIT_SHORT_HASH
|
||||
fi
|
||||
echo $APP_VERSION
|
||||
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT"
|
||||
./scripts/set-version.sh $APP_VERSION
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -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"
|
||||
|
||||
23
.github/helm/affine/charts/gcloud-sql-proxy/.helmignore
vendored
Normal file
23
.github/helm/affine/charts/gcloud-sql-proxy/.helmignore
vendored
Normal file
@@ -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/
|
||||
6
.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml
vendored
Normal file
6
.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml
vendored
Normal file
@@ -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"
|
||||
18
.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt
vendored
Normal file
18
.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt
vendored
Normal file
@@ -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 }}
|
||||
62
.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl
vendored
Normal file
62
.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl
vendored
Normal file
@@ -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 }}
|
||||
132
.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml
vendored
Normal file
132
.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml
vendored
Normal file
@@ -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 }}
|
||||
17
.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml
vendored
Normal file
17
.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml
vendored
Normal file
@@ -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 }}
|
||||
15
.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml
vendored
Normal file
15
.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml
vendored
Normal file
@@ -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 }}
|
||||
17
.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml
vendored
Normal file
17
.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml
vendored
Normal file
@@ -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 }}
|
||||
40
.github/helm/affine/charts/gcloud-sql-proxy/values.yaml
vendored
Normal file
40
.github/helm/affine/charts/gcloud-sql-proxy/values.yaml
vendored
Normal file
@@ -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: {}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
9
.github/helm/affine/charts/sync/Chart.yaml
vendored
9
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/helm/affine/values.yaml
vendored
2
.github/helm/affine/values.yaml
vendored
@@ -16,6 +16,8 @@ global:
|
||||
cloudSqlInternal: ''
|
||||
connectionName: ''
|
||||
serviceAccount: ''
|
||||
cloudProxyReplicas: 3
|
||||
proxyPort: '5432'
|
||||
redis:
|
||||
enabled: true
|
||||
host: 'redis-master'
|
||||
|
||||
16
.github/workflows/build-test.yml
vendored
16
.github/workflows/build-test.yml
vendored
@@ -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:
|
||||
|
||||
27
.github/workflows/deploy.yml
vendored
27
.github/workflows/deploy.yml
vendored
@@ -4,10 +4,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
flavor:
|
||||
description: 'Build type (canary, beta, or stable)'
|
||||
type: string
|
||||
description: 'Select what enverionment to deploy to'
|
||||
type: choice
|
||||
default: canary
|
||||
|
||||
options:
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
- internal
|
||||
env:
|
||||
APP_NAME: affine
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
@@ -18,6 +22,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
@@ -36,6 +42,8 @@ jobs:
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Plugins
|
||||
@@ -67,6 +75,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Rust
|
||||
@@ -88,6 +98,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Rust
|
||||
@@ -207,12 +219,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup deploy version
|
||||
id: version
|
||||
run: |
|
||||
export APP_VERSION=`node -e "console.log(require('./package.json').version)"`
|
||||
echo $APP_VERSION
|
||||
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Setup Version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Deploy to ${{ github.event.inputs.flavor }}
|
||||
uses: ./.github/actions/deploy
|
||||
with:
|
||||
@@ -249,3 +257,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 }}
|
||||
|
||||
2
.github/workflows/workers.yml
vendored
2
.github/workflows/workers.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
environment: production
|
||||
environment: stable
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish
|
||||
|
||||
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -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",
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.10.3-canary.2",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.10.3-canary.2",
|
||||
"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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
2
packages/backend/server/src/cache/index.ts
vendored
2
packages/backend/server/src/cache/index.ts
vendored
@@ -22,3 +22,5 @@ const CacheProvider: FactoryProvider = {
|
||||
})
|
||||
export class CacheModule {}
|
||||
export { LocalCache as Cache };
|
||||
|
||||
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
|
||||
|
||||
99
packages/backend/server/src/cache/interceptor.ts
vendored
Normal file
99
packages/backend/server/src/cache/interceptor.ts
vendored
Normal file
@@ -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<any>
|
||||
): Promise<Observable<any>> {
|
||||
const key = this.reflector.get<CacheConfig | undefined>(
|
||||
'cacheKey',
|
||||
ctx.getHandler()
|
||||
);
|
||||
const preventKey = this.reflector.get<CacheConfig | undefined>(
|
||||
'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<string | null> {
|
||||
const [key, params] = config;
|
||||
|
||||
if (!params) {
|
||||
return key.join(':');
|
||||
} else if (ctx.getType<GqlContextType>() === '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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -73,3 +73,4 @@ export class ConfigModule {
|
||||
}
|
||||
|
||||
export type { AFFiNEConfig } from './def';
|
||||
export { SERVER_FLAVOR } from './default';
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Migration {
|
||||
down: (db: PrismaService) => Promise<void>;
|
||||
}
|
||||
|
||||
async function collectMigrations(): Promise<Migration[]> {
|
||||
export async function collectMigrations(): Promise<Migration[]> {
|
||||
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({
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,10 @@ export class EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
export const OnEvent = (
|
||||
export const OnEvent = RawOnEvent as (
|
||||
event: Event,
|
||||
opts?: Parameters<typeof RawOnEvent>[1]
|
||||
) => {
|
||||
return RawOnEvent(event, opts);
|
||||
};
|
||||
) => MethodDecorator;
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
||||
@@ -29,6 +29,7 @@ import { GQLLoggerPlugin } from './graphql/logger-plugin';
|
||||
context: ({ req, res }: { req: Request; res: Response }) => ({
|
||||
req,
|
||||
res,
|
||||
isAdminQuery: false,
|
||||
}),
|
||||
plugins: [new GQLLoggerPlugin()],
|
||||
};
|
||||
|
||||
@@ -72,15 +72,11 @@ export class MailService {
|
||||
invitationInfo.workspace.name
|
||||
}</span></p><p style="margin-top:8px;margin-bottom:0;">Click button to join this workspace</p>`;
|
||||
|
||||
const subContent =
|
||||
'Currently, AFFiNE Cloud is in the early access stage. Only Early Access Sponsors can register and log in to AFFiNE Cloud. <a href="https://community.affine.pro/c/insider-general/" style="color: #1e67af" >Please click here for more information.</a>';
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'You are invited!',
|
||||
content,
|
||||
buttonContent: 'Accept & Join',
|
||||
buttonUrl,
|
||||
subContent,
|
||||
});
|
||||
|
||||
return this.sendMail({
|
||||
|
||||
@@ -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<NextAuthOptions> = {
|
||||
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<NextAuthOptions> = {
|
||||
}
|
||||
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<NextAuthOptions> = {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
nextAuthOptions.pages = {
|
||||
newUser: '/auth/onboarding',
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaService, MailService, SessionService],
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -464,6 +461,9 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
workspaceId: string,
|
||||
guid: string,
|
||||
doc: Doc,
|
||||
// we always delay the snapshot update to avoid db overload,
|
||||
// so the value of `updatedAt` will not be accurate to user's real action time
|
||||
updatedAt: Date,
|
||||
initialSeq?: number
|
||||
) {
|
||||
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
|
||||
@@ -502,6 +502,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
data: {
|
||||
blob,
|
||||
state,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -521,6 +522,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
blob,
|
||||
state,
|
||||
seq: initialSeq,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -565,7 +568,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
...updates.map(u => u.blob)
|
||||
);
|
||||
|
||||
const done = await this.upsert(workspaceId, id, doc, last.seq);
|
||||
const done = await this.upsert(
|
||||
workspaceId,
|
||||
id,
|
||||
doc,
|
||||
last.createdAt,
|
||||
last.seq
|
||||
);
|
||||
|
||||
if (done) {
|
||||
if (snapshot) {
|
||||
|
||||
78
packages/backend/server/src/modules/features/feature.ts
Normal file
78
packages/backend/server/src/modules/features/feature.ts
Normal file
@@ -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;
|
||||
}
|
||||
21
packages/backend/server/src/modules/features/index.ts
Normal file
21
packages/backend/server/src/modules/features/index.ts
Normal file
@@ -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 };
|
||||
89
packages/backend/server/src/modules/features/management.ts
Normal file
89
packages/backend/server/src/modules/features/management.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
184
packages/backend/server/src/modules/features/service.ts
Normal file
184
packages/backend/server/src/modules/features/service.ts
Normal file
@@ -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<string, number>
|
||||
);
|
||||
}
|
||||
|
||||
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<UserType[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
65
packages/backend/server/src/modules/features/types.ts
Normal file
65
packages/backend/server/src/modules/features/types.ts
Normal file
@@ -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<typeof commonFeatureSchema>;
|
||||
|
||||
/// ======== 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<typeof FeatureSchema>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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<string | null> {
|
||||
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);
|
||||
|
||||
5
packages/backend/server/src/modules/quota/constant.ts
Normal file
5
packages/backend/server/src/modules/quota/constant.ts
Normal file
@@ -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'];
|
||||
21
packages/backend/server/src/modules/quota/index.ts
Normal file
21
packages/backend/server/src/modules/quota/index.ts
Normal file
@@ -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';
|
||||
81
packages/backend/server/src/modules/quota/quota.ts
Normal file
81
packages/backend/server/src/modules/quota/quota.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
|
||||
|
||||
const QuotaCache = new Map<number, QuotaConfig>();
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
50
packages/backend/server/src/modules/quota/schema.ts
Normal file
50
packages/backend/server/src/modules/quota/schema.ts
Normal file
@@ -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,
|
||||
};
|
||||
147
packages/backend/server/src/modules/quota/service.ts
Normal file
147
packages/backend/server/src/modules/quota/service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
54
packages/backend/server/src/modules/quota/storage.ts
Normal file
54
packages/backend/server/src/modules/quota/storage.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
50
packages/backend/server/src/modules/quota/types.ts
Normal file
50
packages/backend/server/src/modules/quota/types.ts
Normal file
@@ -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<typeof QuotaSchema>;
|
||||
|
||||
/// ======== 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`;
|
||||
}
|
||||
@@ -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<EventResponse<{ clientId: string }>> {
|
||||
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<EventResponse<{ clientId: string }>> {
|
||||
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<EventResponse> {
|
||||
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<EventResponse> {
|
||||
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<EventResponse> {
|
||||
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<EventResponse<{ accepted: true }>> {
|
||||
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<EventResponse<{ missing: string; state?: string }>> {
|
||||
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<EventResponse<{ clientId: string }>> {
|
||||
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<EventResponse> {
|
||||
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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<UserFeatureGate, 'freeUser' | 'proUser'>;
|
||||
|
||||
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<UserFeatureGate, 'earlyAccessPreview'>;
|
||||
|
||||
export const FeatureGates = {
|
||||
...UserType,
|
||||
...UserLevel,
|
||||
} satisfies UserFeatureGate;
|
||||
@@ -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';
|
||||
|
||||
@@ -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<User> {
|
||||
@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<AddToNewFeaturesWaitingList> {
|
||||
if (!isStaff(user.email)) {
|
||||
): Promise<number> {
|
||||
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<number> {
|
||||
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<UserType[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<User> {
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function isStaff(email: string) {
|
||||
return email.endsWith('@toeverything.info');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
packages/backend/server/tests/feature.spec.ts
Normal file
143
packages/backend/server/tests/feature.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/// <reference types="../src/global.d.ts" />
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
133
packages/backend/server/tests/quota.spec.ts
Normal file
133
packages/backend/server/tests/quota.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/// <reference types="../src/global.d.ts" />
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<UserType & { token: TokenType }> {
|
||||
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<WorkspaceType> {
|
||||
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<WorkspaceType> {
|
||||
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<WorkspaceType> {
|
||||
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<boolean> {
|
||||
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<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 {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<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 {
|
||||
listBlobs(workspaceId: "${workspaceId}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.listBlobs;
|
||||
}
|
||||
|
||||
async function collectBlobSizes(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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<InvitationType> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<UserType & { token: TokenType }> {
|
||||
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,
|
||||
};
|
||||
112
packages/backend/server/tests/utils/blobs.ts
Normal file
112
packages/backend/server/tests/utils/blobs.ts
Normal file
@@ -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<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 {
|
||||
listBlobs(workspaceId: "${workspaceId}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.listBlobs;
|
||||
}
|
||||
|
||||
export async function getWorkspaceBlobsSize(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
1
packages/backend/server/tests/utils/common.ts
Normal file
1
packages/backend/server/tests/utils/common.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const gql = '/graphql';
|
||||
5
packages/backend/server/tests/utils/index.ts
Normal file
5
packages/backend/server/tests/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './blobs';
|
||||
export * from './invite';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
export * from './workspace';
|
||||
121
packages/backend/server/tests/utils/invite.ts
Normal file
121
packages/backend/server/tests/utils/invite.ts
Normal file
@@ -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<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 {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<InvitationType> {
|
||||
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;
|
||||
}
|
||||
117
packages/backend/server/tests/utils/user.ts
Normal file
117
packages/backend/server/tests/utils/user.ts
Normal file
@@ -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<UserType & { token: TokenType }> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<UserType & { token: TokenType }> {
|
||||
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;
|
||||
}
|
||||
82
packages/backend/server/tests/utils/utils.ts
Normal file
82
packages/backend/server/tests/utils/utils.ts
Normal file
@@ -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');
|
||||
}
|
||||
172
packages/backend/server/tests/utils/workspace.ts
Normal file
172
packages/backend/server/tests/utils/workspace.ts
Normal file
@@ -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<WorkspaceType> {
|
||||
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<WorkspaceType> {
|
||||
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<WorkspaceType> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/storage",
|
||||
"version": "0.10.3-canary.2",
|
||||
"version": "0.11.0",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"version": "0.10.3-canary.2"
|
||||
"version": "0.11.0"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.9",
|
||||
"vitest": "0.34.6"
|
||||
"vitest": "1.0.4"
|
||||
},
|
||||
"version": "0.10.3-canary.2"
|
||||
"version": "0.11.0"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user