mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-07 01:53:45 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd676611ce | ||
|
|
f3bb2be5ef | ||
|
|
8535b3dc41 | ||
|
|
89cc9b072b | ||
|
|
e4b5b24fdd | ||
|
|
9904f50e0b | ||
|
|
b7ac7caab4 | ||
|
|
d74087fdc5 | ||
|
|
875565d08a | ||
|
|
0ecd915245 | ||
|
|
b5ebd20314 | ||
|
|
c102e2454f | ||
|
|
5fc3258a3d | ||
|
|
1a9863d36f | ||
|
|
35c2ad262f | ||
|
|
a0613b6306 | ||
|
|
c18840038f | ||
|
|
e2de0e0e3d | ||
|
|
6fb0ff9177 | ||
|
|
c2fb6adfd8 | ||
|
|
8aeb8bd0ca | ||
|
|
a47042cbd5 | ||
|
|
2c44d3abc6 | ||
|
|
01c164a78a |
@@ -2,6 +2,8 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
image: mcr.microsoft.com/devcontainers/base:bookworm
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
|
||||
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.22.4"
|
||||
appVersion: "0.25.2"
|
||||
|
||||
2
.github/helm/affine/charts/doc/Chart.yaml
vendored
2
.github/helm/affine/charts/doc/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: doc
|
||||
description: AFFiNE doc server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.22.4"
|
||||
appVersion: "0.25.2"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.22.4"
|
||||
appVersion: "0.25.2"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: renderer
|
||||
description: AFFiNE renderer server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.22.4"
|
||||
appVersion: "0.25.2"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.22.4"
|
||||
appVersion: "0.25.2"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<br>
|
||||
</h1>
|
||||
<a href="https://affine.pro/download">
|
||||
<img alt="affine logo" src="https://cdn.affine.pro/Github_hero_image1.png" style="width: 100%">
|
||||
<img alt="affine logo" src="https://cdn.affine.pro/Github_hero_image2.png" style="width: 100%">
|
||||
</a>
|
||||
<br/>
|
||||
<p align="center">
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4",
|
||||
"version": "0.25.2",
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"msw": "^2.8.4",
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -323,7 +323,8 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
|
||||
private readonly _renderEmbedView = () => {
|
||||
const linkedDoc = this.linkedDoc;
|
||||
const isDeleted = !linkedDoc;
|
||||
const trash = linkedDoc?.meta?.trash;
|
||||
const isDeleted = trash || !linkedDoc;
|
||||
const isLoading = this._loading;
|
||||
const isError = this.isError;
|
||||
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
|
||||
@@ -521,11 +522,6 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
);
|
||||
|
||||
this._setDocUpdatedAt();
|
||||
this.disposables.add(
|
||||
this.store.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
})
|
||||
);
|
||||
|
||||
if (this._referenceToNode) {
|
||||
this._linkedDocMode = this.model.props.params?.mode ?? 'page';
|
||||
@@ -554,6 +550,13 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.store.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
this.refreshData();
|
||||
})
|
||||
);
|
||||
|
||||
this._trackCitationDeleteEvent();
|
||||
}
|
||||
|
||||
|
||||
@@ -357,10 +357,14 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this._error = true;
|
||||
});
|
||||
this._load()
|
||||
.then(() => {
|
||||
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
this._error = true;
|
||||
});
|
||||
};
|
||||
|
||||
title$ = computed(() => {
|
||||
@@ -445,7 +449,8 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
this._cycle = false;
|
||||
|
||||
const syncedDoc = this.syncedDoc;
|
||||
if (!syncedDoc) {
|
||||
const trash = syncedDoc?.meta?.trash;
|
||||
if (trash || !syncedDoc) {
|
||||
this._deleted = true;
|
||||
this._loading = false;
|
||||
return;
|
||||
@@ -521,6 +526,7 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
this.disposables.add(
|
||||
this.store.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
this.refreshData();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -49,5 +49,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -67,5 +67,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -82,5 +82,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -51,5 +51,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.21.0"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -47,5 +47,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -56,5 +56,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,5 @@
|
||||
"devDependencies": {
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -55,5 +55,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._onImportSuccess([entryId], {
|
||||
this._onImportSuccess(entryId ? [entryId] : [], {
|
||||
isWorkspaceFile,
|
||||
importedCount: pageIds.length,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,28 @@ type ImportNotionZipOptions = {
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type PageIcon = {
|
||||
type: 'emoji' | 'image';
|
||||
content: string; // emoji unicode or image URL/data
|
||||
};
|
||||
|
||||
type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
icon?: PageIcon;
|
||||
};
|
||||
|
||||
type ImportNotionZipResult = {
|
||||
entryId: string | undefined;
|
||||
pageIds: string[];
|
||||
isWorkspaceFile: boolean;
|
||||
hasMarkdown: boolean;
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
};
|
||||
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
@@ -29,6 +51,197 @@ function getProvider(extensions: ExtensionType[]) {
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
function parseFolderPath(filePath: string): {
|
||||
folderParts: string[];
|
||||
fileName: string;
|
||||
} {
|
||||
const parts = filePath.split('/');
|
||||
const fileName = parts.pop() || '';
|
||||
return { folderParts: parts.filter(part => part.length > 0), fileName };
|
||||
}
|
||||
|
||||
function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
// Look for Notion page icon in the HTML
|
||||
// Notion export format: <div class="page-header-icon undefined"><span class="icon">✅</span></div>
|
||||
|
||||
console.log('=== Extracting page icon ===');
|
||||
|
||||
// Check if there's a head section with title for debugging
|
||||
const headTitle = doc.querySelector('head title');
|
||||
if (headTitle) {
|
||||
console.log('Page title from head:', headTitle.textContent);
|
||||
}
|
||||
|
||||
// Look for the exact Notion export structure: .page-header-icon .icon
|
||||
const notionIconSpan = doc.querySelector('.page-header-icon .icon');
|
||||
if (notionIconSpan && notionIconSpan.textContent) {
|
||||
const iconContent = notionIconSpan.textContent.trim();
|
||||
console.log('Found Notion icon (.page-header-icon .icon):', iconContent);
|
||||
if (/\p{Emoji}/u.test(iconContent)) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: iconContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Look for page header area for debugging
|
||||
const pageHeader = doc.querySelector('.page-header-icon');
|
||||
if (pageHeader) {
|
||||
console.log(
|
||||
'Found .page-header-icon:',
|
||||
pageHeader.outerHTML.substring(0, 300) + '...'
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: try to find emoji icons with older selectors
|
||||
const emojiIcon = doc.querySelector('.page-header-icon .notion-emoji');
|
||||
if (emojiIcon && emojiIcon.textContent) {
|
||||
console.log(
|
||||
'Found emoji icon (.page-header-icon .notion-emoji):',
|
||||
emojiIcon.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Try alternative emoji selectors
|
||||
const altEmojiIcon = doc.querySelector('[role="img"][aria-label]');
|
||||
if (
|
||||
altEmojiIcon &&
|
||||
altEmojiIcon.textContent &&
|
||||
/\p{Emoji}/u.test(altEmojiIcon.textContent)
|
||||
) {
|
||||
console.log(
|
||||
'Found emoji icon ([role="img"][aria-label]):',
|
||||
altEmojiIcon.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: altEmojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Look for image icons in the page header
|
||||
const imageIcon = doc.querySelector('.page-header-icon img');
|
||||
if (imageIcon) {
|
||||
const src = imageIcon.getAttribute('src');
|
||||
console.log('Found image icon (.page-header-icon img):', src);
|
||||
if (src) {
|
||||
return {
|
||||
type: 'image',
|
||||
content: src,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Look for any span with emoji class "icon" in page header area
|
||||
const iconSpans = doc.querySelectorAll('span.icon');
|
||||
for (const span of iconSpans) {
|
||||
if (span.textContent && /\p{Emoji}/u.test(span.textContent.trim())) {
|
||||
const parent = span.parentElement;
|
||||
console.log(
|
||||
'Found emoji in span.icon:',
|
||||
span.textContent,
|
||||
'parent classes:',
|
||||
parent?.className
|
||||
);
|
||||
// Check if this is in a page header context
|
||||
if (
|
||||
parent &&
|
||||
(parent.classList.contains('page-header-icon') ||
|
||||
parent.closest('.page-header-icon'))
|
||||
) {
|
||||
console.log(
|
||||
'Using emoji from span.icon in page header:',
|
||||
span.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: span.textContent.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to find icons in the page title area that might contain emoji
|
||||
const pageTitle = doc.querySelector('.page-title, h1');
|
||||
if (pageTitle && pageTitle.textContent) {
|
||||
console.log('Page title element found:', pageTitle.textContent);
|
||||
const text = pageTitle.textContent.trim();
|
||||
// Check if the title starts with an emoji
|
||||
const emojiMatch = text.match(/^(\p{Emoji}+)/u);
|
||||
if (emojiMatch) {
|
||||
console.log('Found emoji in title:', emojiMatch[1]);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiMatch[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log('No page icon found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildFolderHierarchy(
|
||||
pagePaths: Array<{ path: string; pageId: string; icon?: PageIcon }>
|
||||
): FolderHierarchy {
|
||||
const root: FolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
for (const { path, pageId, icon } of pagePaths) {
|
||||
const { folderParts, fileName } = parseFolderPath(path);
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
// Navigate/create folder structure
|
||||
for (const folderName of folderParts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
|
||||
if (!current.children.has(folderName)) {
|
||||
current.children.set(folderName, {
|
||||
name: folderName,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(folderName)!;
|
||||
}
|
||||
|
||||
// If this is a page file, associate it with the current folder
|
||||
if (fileName.endsWith('.html') && !fileName.startsWith('index.html')) {
|
||||
const pageName = fileName.replace(/\.html$/, '');
|
||||
if (!current.children.has(pageName)) {
|
||||
current.children.set(pageName, {
|
||||
name: pageName,
|
||||
path: path,
|
||||
parentPath: current.path || undefined,
|
||||
children: new Map(),
|
||||
pageId: pageId,
|
||||
icon: icon,
|
||||
});
|
||||
} else {
|
||||
// Update existing entry with pageId and icon
|
||||
const existingPage = current.children.get(pageName)!;
|
||||
existingPage.pageId = pageId;
|
||||
if (icon) {
|
||||
existingPage.icon = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a Notion zip file into the BlockSuite collection.
|
||||
*
|
||||
@@ -42,18 +255,24 @@ function getProvider(extensions: ExtensionType[]) {
|
||||
* - pageIds: An array of imported page IDs.
|
||||
* - isWorkspaceFile: Whether the imported file is a workspace file.
|
||||
* - hasMarkdown: Whether the zip contains markdown files.
|
||||
* - folderHierarchy: The parsed folder hierarchy from the Notion export.
|
||||
*/
|
||||
async function importNotionZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportNotionZipOptions) {
|
||||
}: ImportNotionZipOptions): Promise<ImportNotionZipResult> {
|
||||
const provider = getProvider(extensions);
|
||||
const pageIds: string[] = [];
|
||||
let isWorkspaceFile = false;
|
||||
let hasMarkdown = false;
|
||||
let entryId: string | undefined;
|
||||
const pagePathsWithIds: Array<{
|
||||
path: string;
|
||||
pageId: string;
|
||||
icon?: PageIcon;
|
||||
}> = [];
|
||||
const parseZipFile = async (path: File | Blob) => {
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(path);
|
||||
@@ -80,6 +299,8 @@ async function importNotionZip({
|
||||
isWorkspaceFile = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pageIcon: PageIcon | undefined;
|
||||
if (lastSplitIndex !== -1) {
|
||||
const text = await content.text();
|
||||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||
@@ -88,7 +309,10 @@ async function importNotionZip({
|
||||
// Skip empty pages
|
||||
continue;
|
||||
}
|
||||
// Extract page icon from the HTML
|
||||
pageIcon = extractPageIcon(doc);
|
||||
}
|
||||
|
||||
const id = collection.idGenerator();
|
||||
const splitPath = path.split('/');
|
||||
while (splitPath.length > 0) {
|
||||
@@ -96,6 +320,7 @@ async function importNotionZip({
|
||||
splitPath.shift();
|
||||
}
|
||||
pagePaths.push(path);
|
||||
pagePathsWithIds.push({ path, pageId: id, icon: pageIcon });
|
||||
if (entryId === undefined && lastSplitIndex === -1) {
|
||||
entryId = id;
|
||||
}
|
||||
@@ -166,7 +391,14 @@ async function importNotionZip({
|
||||
const allPromises = await parseZipFile(imported);
|
||||
await Promise.all(allPromises.flat());
|
||||
entryId = entryId ?? pageIds[0];
|
||||
return { entryId, pageIds, isWorkspaceFile, hasMarkdown };
|
||||
|
||||
// Build folder hierarchy from collected paths
|
||||
const folderHierarchy =
|
||||
pagePathsWithIds.length > 0
|
||||
? buildFolderHierarchy(pagePathsWithIds)
|
||||
: undefined;
|
||||
|
||||
return { entryId, pageIds, isWorkspaceFile, hasMarkdown, folderHierarchy };
|
||||
}
|
||||
|
||||
export const NotionHtmlTransformer = {
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -64,5 +64,5 @@
|
||||
"devDependencies": {
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -47,5 +47,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export class Clipboard extends LifeCycleWatcher {
|
||||
const byPriority = Array.from(this._adapters).sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
);
|
||||
|
||||
for (const { adapter, mimeType } of byPriority) {
|
||||
const item = getItem(mimeType);
|
||||
if (Array.isArray(item)) {
|
||||
@@ -170,7 +171,9 @@ export class Clipboard extends LifeCycleWatcher {
|
||||
index?: number
|
||||
) => {
|
||||
const data = event.clipboardData;
|
||||
if (!data) return;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = this.readFromClipboard(data);
|
||||
@@ -187,7 +190,7 @@ export class Clipboard extends LifeCycleWatcher {
|
||||
);
|
||||
}
|
||||
return slice;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const getDataByType = this._getDataByType(data);
|
||||
const slice = await this._getSnapshotByPriority(
|
||||
type => getDataByType(type),
|
||||
@@ -195,7 +198,6 @@ export class Clipboard extends LifeCycleWatcher {
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
return slice;
|
||||
}
|
||||
};
|
||||
@@ -292,9 +294,7 @@ export class Clipboard extends LifeCycleWatcher {
|
||||
|
||||
if (image) {
|
||||
const type = 'image/png';
|
||||
|
||||
delete items[type];
|
||||
|
||||
if (typeof image === 'string') {
|
||||
clipboardItems[type] = new Blob([image], { type });
|
||||
} else if (image instanceof Blob) {
|
||||
@@ -314,7 +314,7 @@ export class Clipboard extends LifeCycleWatcher {
|
||||
if (hasInnerHTML || isEmpty) {
|
||||
const type = 'text/html';
|
||||
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
||||
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
|
||||
const html = `<div data-blocksuite-snapshot="${snapshot}">${innerHTML}</div>`;
|
||||
clipboardItems[type] = new Blob([html], { type });
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export class ClipboardControl {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
|
||||
this._dispatcher.run(
|
||||
'paste',
|
||||
this._createContext(event, clipboardEventState)
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!dist/__tests__",
|
||||
"shim.d.ts"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface DocMeta {
|
||||
createDate: number;
|
||||
updatedDate?: number;
|
||||
favorite?: boolean;
|
||||
trash?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceMeta {
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"vite": "^6.1.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-istanbul": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"graphql": "^16.9.0",
|
||||
"magic-string": "^0.30.11",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-istanbul": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.22.4"
|
||||
"version": "0.25.2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.22.4",
|
||||
"version": "0.25.2",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -89,7 +89,7 @@
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/server-native",
|
||||
"version": "0.22.4",
|
||||
"version": "0.25.2",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.22.4",
|
||||
"version": "0.25.2",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -56,20 +56,20 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.29.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.57.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.29.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.4",
|
||||
"@opentelemetry/instrumentation": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.44.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.46.0",
|
||||
"@opentelemetry/resources": "^1.29.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.29.0",
|
||||
"@opentelemetry/sdk-node": "^0.57.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.29.0",
|
||||
"@opentelemetry/core": "^1.30.1",
|
||||
"@opentelemetry/exporter-prometheus": "^0.57.2",
|
||||
"@opentelemetry/exporter-zipkin": "^1.30.1",
|
||||
"@opentelemetry/host-metrics": "^0.36.0",
|
||||
"@opentelemetry/instrumentation": "^0.57.2",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.57.2",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.54.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.54.0",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.57.2",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@prisma/instrumentation": "^6.7.0",
|
||||
|
||||
@@ -42,6 +42,7 @@ type Ctx = {
|
||||
controller: RevenueCatWebhookController;
|
||||
subResolver: UserSubscriptionResolver;
|
||||
|
||||
mockAlias: (appUserId: string) => Sinon.SinonStub;
|
||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||
triggerWebhook: (
|
||||
@@ -100,13 +101,20 @@ test.beforeEach(async t => {
|
||||
t.context.controller = controller;
|
||||
t.context.subResolver = subResolver;
|
||||
|
||||
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||
const customerId = 'cust';
|
||||
t.context.mockAlias = appUserId =>
|
||||
Sinon.stub(rc, 'getCustomerAlias').resolves([appUserId]);
|
||||
t.context.mockSub = subs =>
|
||||
Sinon.stub(rc, 'getSubscriptions').resolves(
|
||||
subs.map(s => ({ ...s, customerId: customerId }))
|
||||
);
|
||||
t.context.mockSubSeq = sequences => {
|
||||
const stub = Sinon.stub(rc, 'getSubscriptions');
|
||||
sequences.forEach((seq, idx) => {
|
||||
if (idx === 0) stub.onFirstCall().resolves(seq);
|
||||
else if (idx === 1) stub.onSecondCall().resolves(seq);
|
||||
else stub.onCall(idx).resolves(seq);
|
||||
const subs = seq.map(s => ({ ...s, customerId: customerId }));
|
||||
if (idx === 0) stub.onFirstCall().resolves(subs);
|
||||
else if (idx === 1) stub.onSecondCall().resolves(subs);
|
||||
else stub.onCall(idx).resolves(subs);
|
||||
});
|
||||
return stub;
|
||||
};
|
||||
@@ -178,8 +186,9 @@ test('should resolve product mapping consistently (whitelist, override, unknown)
|
||||
});
|
||||
|
||||
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
|
||||
const { webhook, collectEvents, mockSub } = t.context;
|
||||
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
const subscriber = mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
@@ -234,8 +243,9 @@ test('should standardize RC subscriber response and upsert subscription with obs
|
||||
});
|
||||
|
||||
test('should process expiration/refund by deleting subscription and emitting canceled', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
@@ -339,8 +349,10 @@ test('should enqueue per-user reconciliation jobs for existing RC active/trialin
|
||||
});
|
||||
|
||||
test('should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)', async t => {
|
||||
const { db, event, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
const { db, event, collectEvents, mockAlias, mockSubSeq, triggerWebhook } =
|
||||
t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Pro monthly on iOS',
|
||||
@@ -422,7 +434,9 @@ test('should activate subscriptions via webhook for whitelisted products across
|
||||
});
|
||||
|
||||
test('should keep active and advance period dates when a trialing subscription renews', async t => {
|
||||
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSubSeq, triggerWebhook } =
|
||||
t.context;
|
||||
mockAlias(user.id);
|
||||
mockSubSeq([
|
||||
[
|
||||
{
|
||||
@@ -476,7 +490,9 @@ test('should keep active and advance period dates when a trialing subscription r
|
||||
});
|
||||
|
||||
test('should remove or cancel the record and revoke entitlement when a trialing subscription expires', async t => {
|
||||
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSubSeq, triggerWebhook } =
|
||||
t.context;
|
||||
mockAlias(user.id);
|
||||
mockSubSeq([
|
||||
[
|
||||
{
|
||||
@@ -497,7 +513,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing
|
||||
isTrial: false,
|
||||
isActive: false,
|
||||
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2024-01-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: false,
|
||||
@@ -526,7 +542,8 @@ test('should remove or cancel the record and revoke entitlement when a trialing
|
||||
});
|
||||
|
||||
test('should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||
mockAlias(user.id);
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
@@ -563,7 +580,8 @@ test('should set canceledAt and keep active until expiration when will_renew is
|
||||
});
|
||||
|
||||
test('should retain record as past_due (inactive but not expired) and NOT emit canceled event', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||
mockAlias(user.id);
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
@@ -656,7 +674,8 @@ test('should block checkout when an existing subscription of the same plan is ac
|
||||
});
|
||||
|
||||
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||
mockAlias(user.id);
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
@@ -732,8 +751,9 @@ test('should block read-write ops on revenuecat-managed record (cancel/resume/up
|
||||
});
|
||||
|
||||
test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => {
|
||||
const { webhook, collectEvents, mockSub } = t.context;
|
||||
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
const subscriber = mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
@@ -759,8 +779,9 @@ test('should reconcile and fix missing or out-of-order states for revenuecat Act
|
||||
});
|
||||
|
||||
test('should treat refund as early expiration and revoke immediately', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
@@ -803,7 +824,9 @@ test('should treat refund as early expiration and revoke immediately', async t =
|
||||
});
|
||||
|
||||
test('should ignore non-whitelisted productId and not write to DB', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Weird',
|
||||
@@ -831,8 +854,10 @@ test('should ignore non-whitelisted productId and not write to DB', async t => {
|
||||
});
|
||||
|
||||
test('should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)', async t => {
|
||||
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
const { db, collectEvents, mockAlias, mockSubSeq, triggerWebhook } =
|
||||
t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
mockSubSeq([
|
||||
[
|
||||
{
|
||||
@@ -933,8 +958,9 @@ test('should not dispatch webhook event when authorization header is missing or
|
||||
});
|
||||
|
||||
test('should refresh user subscriptions (empty / revenuecat / stripe-only)', async t => {
|
||||
const { subResolver, db, mockSubSeq } = t.context;
|
||||
const { subResolver, db, mockAlias, mockSubSeq } = t.context;
|
||||
|
||||
mockAlias(user.id);
|
||||
const currentUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -2,13 +2,17 @@ import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createSign,
|
||||
createVerify,
|
||||
generateKeyPairSync,
|
||||
type KeyObject,
|
||||
randomBytes,
|
||||
randomInt,
|
||||
sign,
|
||||
timingSafeEqual,
|
||||
verify,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
@@ -32,20 +36,31 @@ function generatePrivateKey(): string {
|
||||
namedCurve: 'prime256v1',
|
||||
});
|
||||
|
||||
// Export EC private key as PKCS#8 PEM. This avoids OpenSSL 3.x decoder issues
|
||||
// in Node.js 22 when later deriving the public key via createPublicKey.
|
||||
const key = privateKey.export({
|
||||
type: 'sec1',
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
});
|
||||
|
||||
return key.toString('utf8');
|
||||
}
|
||||
|
||||
function generatePublicKey(privateKey: string) {
|
||||
return createPublicKey({
|
||||
key: Buffer.from(privateKey),
|
||||
})
|
||||
.export({ format: 'pem', type: 'spki' })
|
||||
.toString('utf8');
|
||||
function parseKey(privateKey: string) {
|
||||
const keyBuf = Buffer.from(privateKey);
|
||||
let priv: KeyObject;
|
||||
try {
|
||||
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'pkcs8' });
|
||||
} catch (e1) {
|
||||
try {
|
||||
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'sec1' });
|
||||
} catch (e2) {
|
||||
// As a last resort rely on auto-detection
|
||||
priv = createPrivateKey(keyBuf);
|
||||
}
|
||||
}
|
||||
const pub = createPublicKey(priv);
|
||||
return { priv, pub };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -53,8 +68,8 @@ export class CryptoHelper implements OnModuleInit {
|
||||
logger = new Logger(CryptoHelper.name);
|
||||
|
||||
keyPair!: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
publicKey: KeyObject;
|
||||
privateKey: KeyObject;
|
||||
sha256: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
@@ -87,11 +102,14 @@ export class CryptoHelper implements OnModuleInit {
|
||||
|
||||
private setup() {
|
||||
const privateKey = this.config.crypto.privateKey || generatePrivateKey();
|
||||
const publicKey = generatePublicKey(privateKey);
|
||||
const { priv, pub } = parseKey(privateKey);
|
||||
const publicKey = pub
|
||||
.export({ format: 'pem', type: 'spki' })
|
||||
.toString('utf8');
|
||||
|
||||
this.keyPair = {
|
||||
publicKey: Buffer.from(publicKey),
|
||||
privateKey: Buffer.from(privateKey),
|
||||
publicKey: pub,
|
||||
privateKey: priv,
|
||||
sha256: {
|
||||
publicKey: this.sha256(publicKey),
|
||||
privateKey: this.sha256(privateKey),
|
||||
@@ -99,11 +117,23 @@ export class CryptoHelper implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
private get keyType() {
|
||||
return (this.keyPair.privateKey.asymmetricKeyType as string) || 'ec';
|
||||
}
|
||||
|
||||
sign(data: string) {
|
||||
const sign = createSign('rsa-sha256');
|
||||
sign.update(data, 'utf-8');
|
||||
sign.end();
|
||||
return `${data},${sign.sign(this.keyPair.privateKey, 'base64')}`;
|
||||
const input = Buffer.from(data, 'utf-8');
|
||||
if (this.keyType === 'ed25519') {
|
||||
// Ed25519 signs the message directly (no pre-hash)
|
||||
const sig = sign(null, input, this.keyPair.privateKey);
|
||||
return `${data},${sig.toString('base64')}`;
|
||||
} else {
|
||||
// ECDSA with SHA-256 for EC keys
|
||||
const sign = createSign('sha256');
|
||||
sign.update(input);
|
||||
sign.end();
|
||||
return `${data},${sign.sign(this.keyPair.privateKey, 'base64')}`;
|
||||
}
|
||||
}
|
||||
|
||||
verify(signatureWithData: string) {
|
||||
@@ -111,10 +141,18 @@ export class CryptoHelper implements OnModuleInit {
|
||||
if (!signature) {
|
||||
return false;
|
||||
}
|
||||
const verify = createVerify('rsa-sha256');
|
||||
verify.update(data, 'utf-8');
|
||||
verify.end();
|
||||
return verify.verify(this.keyPair.privateKey, signature, 'base64');
|
||||
const input = Buffer.from(data, 'utf-8');
|
||||
const sigBuf = Buffer.from(signature, 'base64');
|
||||
if (this.keyType === 'ed25519') {
|
||||
// Ed25519 verifies the message directly
|
||||
return verify(null, input, this.keyPair.publicKey, sigBuf);
|
||||
} else {
|
||||
// ECDSA with SHA-256
|
||||
const verify = createVerify('sha256');
|
||||
verify.update(input);
|
||||
verify.end();
|
||||
return verify.verify(this.keyPair.publicKey, sigBuf);
|
||||
}
|
||||
}
|
||||
|
||||
encrypt(data: string) {
|
||||
@@ -179,7 +217,7 @@ export class CryptoHelper implements OnModuleInit {
|
||||
let otp = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
otp += this.randomInt(0, 9).toString();
|
||||
otp += this.randomInt(0, 10).toString();
|
||||
}
|
||||
|
||||
return otp;
|
||||
|
||||
@@ -1,4 +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 OneMinute = 1000 * 60;
|
||||
export const OneDay = OneMinute * 60 * 24;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AccessDenied,
|
||||
AuthenticationRequired,
|
||||
FailedToCheckout,
|
||||
InvalidSubscriptionParameters,
|
||||
Throttle,
|
||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||
} from '../../base';
|
||||
@@ -543,6 +544,56 @@ export class UserSubscriptionResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Mutation(() => [SubscriptionType], {
|
||||
description: 'Request to apply the subscription in advance',
|
||||
})
|
||||
async requestApplySubscription(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('transactionId') transactionId: string
|
||||
): Promise<Subscription[]> {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
let existsSubscription = await this.db.subscription.findFirst({
|
||||
where: { rcExternalRef: transactionId },
|
||||
});
|
||||
|
||||
// subscription with the transactionId already exists
|
||||
if (existsSubscription) {
|
||||
if (existsSubscription.targetId !== user.id) {
|
||||
throw new InvalidSubscriptionParameters();
|
||||
} else {
|
||||
this.normalizeSubscription(existsSubscription);
|
||||
return [existsSubscription];
|
||||
}
|
||||
}
|
||||
|
||||
let current: Subscription[] = [];
|
||||
|
||||
try {
|
||||
await this.rcHandler.syncAppUserWithExternalRef(user.id, transactionId);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// ignore errors
|
||||
} catch {}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Mutation(() => [SubscriptionType], {
|
||||
description: 'Refresh current user subscriptions and return latest.',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Headers, Logger, Post } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config, EventBus } from '../../../base';
|
||||
import { Config, EventBus, JobQueue } from '../../../base';
|
||||
import { Public } from '../../../core/auth';
|
||||
import { FeatureService } from '../../../core/features';
|
||||
import { Models } from '../../../models';
|
||||
|
||||
const RcEventSchema = z
|
||||
.object({
|
||||
@@ -52,7 +54,10 @@ export class RevenueCatWebhookController {
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus
|
||||
private readonly event: EventBus,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly models: Models,
|
||||
private readonly feature: FeatureService
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -70,28 +75,63 @@ export class RevenueCatWebhookController {
|
||||
if (parsed.success) {
|
||||
const event = parsed.data.event;
|
||||
const { id, app_user_id: appUserId, type } = event;
|
||||
|
||||
if (
|
||||
event.environment.toLowerCase() === environment?.toLowerCase()
|
||||
) {
|
||||
const logParams = {
|
||||
appUserId,
|
||||
familyShare: event.is_family_share,
|
||||
environment: event.environment,
|
||||
transactionId: event.transaction_id,
|
||||
};
|
||||
this.logger.log(
|
||||
`[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.`
|
||||
);
|
||||
|
||||
if (
|
||||
appUserId &&
|
||||
(typeof event.is_family_share !== 'boolean' ||
|
||||
!event.is_family_share)
|
||||
) {
|
||||
// immediately ack and process asynchronously
|
||||
this.event
|
||||
.emitAsync('revenuecat.webhook', { appUserId, event })
|
||||
if (appUserId && !appUserId.startsWith('$RCAnonymousID:')) {
|
||||
const user = await this.models.user.get(appUserId);
|
||||
if (user) {
|
||||
if (
|
||||
(typeof event.is_family_share !== 'boolean' ||
|
||||
!event.is_family_share) &&
|
||||
(environment.toLowerCase() === 'production' ||
|
||||
this.feature.isStaff(user.email))
|
||||
) {
|
||||
// immediately ack and process asynchronously
|
||||
this.event
|
||||
.emitAsync('revenuecat.webhook', { appUserId, event })
|
||||
.catch((e: Error) => {
|
||||
this.logger.error(
|
||||
'Failed to handle RevenueCat Webhook event.',
|
||||
e
|
||||
);
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${id}] RevenueCat Webhook received for non-acceptable params.`,
|
||||
logParams
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (event.transaction_id) {
|
||||
await this.queue
|
||||
.add('nightly.revenuecat.subscription.refresh.anonymous', {
|
||||
externalRef: event.transaction_id,
|
||||
startTime: Date.now(),
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
this.logger.error(
|
||||
'Failed to handle RevenueCat Webhook event.',
|
||||
e
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
`RevenueCat Webhook received for unknown user`,
|
||||
logParams
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
@@ -18,23 +18,25 @@ const Store = z.enum([
|
||||
const zRcV2RawProduct = z
|
||||
.object({
|
||||
id: z.string().nonempty(),
|
||||
display_name: z.string().nonempty(),
|
||||
store_identifier: z.string().nonempty(),
|
||||
subscription: z
|
||||
.object({ duration: z.string().nullable() })
|
||||
.partial()
|
||||
.nullable(),
|
||||
app: z.object({ type: Store }).partial(),
|
||||
app: z.object({ type: Store }).partial().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawEntitlementItem = z
|
||||
.object({
|
||||
id: z.string().nonempty(),
|
||||
lookup_key: z.string().nonempty(),
|
||||
display_name: z.string().nonempty(),
|
||||
products: z
|
||||
.object({ items: z.array(zRcV2RawProduct).default([]) })
|
||||
.partial()
|
||||
.nullable(),
|
||||
.nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -45,6 +47,9 @@ const zRcV2RawEntitlements = z
|
||||
const zRcV2RawSubscription = z
|
||||
.object({
|
||||
object: z.enum(['subscription']),
|
||||
id: z.string().nonempty(),
|
||||
customer_id: z.string().nonempty().nullish(),
|
||||
product_id: z.string().nonempty().nullable(),
|
||||
entitlements: zRcV2RawEntitlements,
|
||||
starts_at: z.number(),
|
||||
current_period_ends_at: z.number().nullable(),
|
||||
@@ -71,11 +76,25 @@ const zRcV2RawSubscription = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawEnvelope = z
|
||||
const zRcV2RawSubscriptionEnvelope = z
|
||||
.object({
|
||||
app_user_id: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
subscriptions: z.array(zRcV2RawSubscription).default([]),
|
||||
items: z.array(zRcV2RawSubscription).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawCustomerAlias = z
|
||||
.object({
|
||||
object: z.literal('customer.alias'),
|
||||
id: z.string().nonempty(),
|
||||
created_at: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawCustomerAliasEnvelope = z
|
||||
.object({
|
||||
items: z.array(zRcV2RawCustomerAlias).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -86,6 +105,7 @@ export const Subscription = z.object({
|
||||
isActive: z.boolean(),
|
||||
latestPurchaseDate: z.date().nullable(),
|
||||
expirationDate: z.date().nullable(),
|
||||
customerId: z.string().optional(),
|
||||
productId: z.string(),
|
||||
store: Store,
|
||||
willRenew: z.boolean(),
|
||||
@@ -93,9 +113,14 @@ export const Subscription = z.object({
|
||||
});
|
||||
|
||||
export type Subscription = z.infer<typeof Subscription>;
|
||||
type Entitlement = z.infer<typeof zRcV2RawEntitlementItem>;
|
||||
type Product = z.infer<typeof zRcV2RawProduct>;
|
||||
|
||||
@Injectable()
|
||||
export class RevenueCatService {
|
||||
private readonly logger = new Logger(RevenueCatService.name);
|
||||
private readonly productsCache = new Map<string, Product[]>();
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
private get apiKey(): string {
|
||||
@@ -114,6 +139,120 @@ export class RevenueCatService {
|
||||
return id;
|
||||
}
|
||||
|
||||
async getProducts(ent: Entitlement): Promise<Product[] | null> {
|
||||
if (ent.products?.items && ent.products.items.length > 0) {
|
||||
return ent.products.items;
|
||||
}
|
||||
const entId = ent.id;
|
||||
if (this.productsCache.has(entId)) {
|
||||
return this.productsCache.get(entId)!;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/entitlements/${entId}?expand=product`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
this.logger.warn(
|
||||
`RevenueCat getProducts failed: ${res.status} ${res.statusText} - ${text}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const entParsed = zRcV2RawEntitlementItem.safeParse(json);
|
||||
if (entParsed.success) {
|
||||
const products = entParsed.data.products?.items || null;
|
||||
if (products) {
|
||||
this.productsCache.set(entId, products);
|
||||
}
|
||||
return products;
|
||||
}
|
||||
this.logger.error(
|
||||
`RevenueCat entitlement ${entId} parse failed: ${JSON.stringify(
|
||||
entParsed.error.format()
|
||||
)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async getCustomerAlias(customerId: string): Promise<string[] | null> {
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/aliases`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(
|
||||
`RevenueCat getCustomerAlias failed: ${res.status} ${res.statusText} - ${text}`
|
||||
);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const customerParsed = zRcV2RawCustomerAliasEnvelope.safeParse(json);
|
||||
|
||||
if (customerParsed.success) {
|
||||
return customerParsed.data.items
|
||||
.map(alias => alias.id)
|
||||
.filter(id => !id.startsWith('$RCAnonymousID:'));
|
||||
}
|
||||
this.logger.error(
|
||||
`RevenueCat customer ${customerId} parse failed: ${JSON.stringify(
|
||||
customerParsed.error.format()
|
||||
)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async getSubscriptionByExternalRef(
|
||||
externalRef: string
|
||||
): Promise<Subscription[] | null> {
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/subscriptions?store_subscription_identifier=${encodeURIComponent(externalRef)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(
|
||||
`RevenueCat getSubscriptionByExternalRef failed: ${res.status} ${res.statusText} - ${text}`
|
||||
);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const envParsed = zRcV2RawSubscriptionEnvelope.safeParse(json);
|
||||
|
||||
if (envParsed.success) {
|
||||
const parsedSubs = await Promise.all(
|
||||
envParsed.data.items.flatMap(async sub => this.parseSubscription(sub))
|
||||
);
|
||||
return parsedSubs.filter((s): s is Subscription => s !== null);
|
||||
}
|
||||
this.logger.error(
|
||||
`RevenueCat subscription parse failed: ${JSON.stringify(
|
||||
envParsed.error.format()
|
||||
)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async getSubscriptions(customerId: string): Promise<Subscription[] | null> {
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
|
||||
@@ -132,39 +271,55 @@ export class RevenueCatService {
|
||||
);
|
||||
}
|
||||
|
||||
const envParsed = zRcV2RawEnvelope.safeParse(await res.json());
|
||||
const json = await res.json();
|
||||
const envParsed = zRcV2RawSubscriptionEnvelope.safeParse(json);
|
||||
|
||||
if (envParsed.success) {
|
||||
return envParsed.data.subscriptions
|
||||
.flatMap(sub => {
|
||||
const items = sub.entitlements.items ?? [];
|
||||
return items.map(ent => {
|
||||
const product = ent.products?.items?.[0];
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
identifier: ent.lookup_key,
|
||||
isTrial: sub.status === 'trialing',
|
||||
isActive:
|
||||
sub.gives_access === true ||
|
||||
sub.status === 'active' ||
|
||||
sub.status === 'trialing',
|
||||
latestPurchaseDate: sub.starts_at
|
||||
? new Date(sub.starts_at * 1000)
|
||||
: null,
|
||||
expirationDate: sub.current_period_ends_at
|
||||
? new Date(sub.current_period_ends_at * 1000)
|
||||
: null,
|
||||
productId: product.store_identifier,
|
||||
store: sub.store ?? product.app.type,
|
||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||
duration: product.subscription?.duration ?? null,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((s): s is Subscription => s !== null);
|
||||
const parsedSubs = await Promise.all(
|
||||
envParsed.data.items.flatMap(async sub => this.parseSubscription(sub))
|
||||
);
|
||||
return parsedSubs.filter((s): s is Subscription => s !== null);
|
||||
}
|
||||
this.logger.error(
|
||||
`RevenueCat subscription parse failed: ${JSON.stringify(
|
||||
envParsed.error.format()
|
||||
)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async parseSubscription(
|
||||
sub: z.infer<typeof zRcV2RawSubscription>
|
||||
): Promise<Subscription | null> {
|
||||
const items = sub.entitlements.items ?? [];
|
||||
const products = (await Promise.all(items.map(this.getProducts.bind(this))))
|
||||
.filter((p): p is Product[] => p !== null)
|
||||
.flat();
|
||||
const product = products.find(p => p.id === sub.product_id);
|
||||
if (!product) {
|
||||
this.logger.warn(
|
||||
`RevenueCat subscription ${sub.id} missing product for product_id=${sub.product_id}`,
|
||||
products
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: product.display_name,
|
||||
isTrial: sub.status === 'trialing',
|
||||
isActive:
|
||||
sub.gives_access === true ||
|
||||
sub.status === 'active' ||
|
||||
sub.status === 'trialing',
|
||||
latestPurchaseDate: sub.starts_at ? new Date(sub.starts_at) : null,
|
||||
expirationDate: sub.current_period_ends_at
|
||||
? new Date(sub.current_period_ends_at)
|
||||
: null,
|
||||
customerId: sub.customer_id || undefined,
|
||||
productId: product.store_identifier,
|
||||
store: sub.store ?? product.app?.type,
|
||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||
duration: product.subscription?.duration ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
import { Config, EventBus, OnEvent } from '../../../base';
|
||||
import {
|
||||
Config,
|
||||
EventBus,
|
||||
JOB_SIGNAL,
|
||||
JobQueue,
|
||||
OneMinute,
|
||||
OnEvent,
|
||||
OnJob,
|
||||
sleep,
|
||||
} from '../../../base';
|
||||
import { SubscriptionStatus } from '../types';
|
||||
import { RcEvent } from './controller';
|
||||
import { resolveProductMapping } from './map';
|
||||
import { RevenueCatService, Subscription } from './service';
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 1000; // 5 seconds
|
||||
const REFRESH_MAX_TIMES = 10 * OneMinute;
|
||||
|
||||
@Injectable()
|
||||
export class RevenueCatWebhookHandler {
|
||||
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
|
||||
@@ -15,7 +27,8 @@ export class RevenueCatWebhookHandler {
|
||||
private readonly rc: RevenueCatService,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus
|
||||
private readonly event: EventBus,
|
||||
private readonly queue: JobQueue
|
||||
) {}
|
||||
|
||||
@OnEvent('revenuecat.webhook')
|
||||
@@ -30,48 +43,121 @@ export class RevenueCatWebhookHandler {
|
||||
await this.syncAppUser(appUserId, evt.event);
|
||||
}
|
||||
|
||||
// NOTE: add subscription to user before the subscription event is received
|
||||
// will expire after a short duration if not confirmed by webhook
|
||||
async syncAppUserWithExternalRef(appUserId: string, externalRef: string) {
|
||||
// Pull latest state to be resilient to reorder/duplicate events
|
||||
let subscriptions: Awaited<
|
||||
ReturnType<RevenueCatService['getSubscriptions']>
|
||||
>;
|
||||
try {
|
||||
subscriptions = await this.rc.getSubscriptionByExternalRef(externalRef);
|
||||
if (!subscriptions) {
|
||||
throw new Error(`No transaction found: ${externalRef}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to fetch RC subscriptions for ${appUserId} by ${externalRef}`,
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await this.syncSubscription(
|
||||
appUserId,
|
||||
subscriptions,
|
||||
undefined,
|
||||
externalRef,
|
||||
new Date(Date.now() + 10 * OneMinute) // expire after 10 minutes
|
||||
);
|
||||
await this.queue.add('nightly.revenuecat.subscription.refresh', {
|
||||
userId: appUserId,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// Exposed for reuse by reconcile job
|
||||
async syncAppUser(appUserId: string, event?: RcEvent) {
|
||||
async syncAppUser(appUserId: string, event?: RcEvent): Promise<boolean> {
|
||||
// Pull latest state to be resilient to reorder/duplicate events
|
||||
let subscriptions: Awaited<
|
||||
ReturnType<RevenueCatService['getSubscriptions']>
|
||||
>;
|
||||
try {
|
||||
subscriptions = await this.rc.getSubscriptions(appUserId);
|
||||
if (!subscriptions) return;
|
||||
if (!subscriptions) return false;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to fetch RC subscriber for ${appUserId}`, e);
|
||||
return;
|
||||
this.logger.error(`Failed to fetch RC subscription for ${appUserId}`, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.syncSubscription(appUserId, subscriptions, event);
|
||||
}
|
||||
|
||||
private async syncSubscription(
|
||||
appUserId: string,
|
||||
subscriptions: Subscription[],
|
||||
event?: RcEvent,
|
||||
externalRef?: string,
|
||||
overrideExpirationDate?: Date
|
||||
): Promise<boolean> {
|
||||
const productOverride = this.config.payment.revenuecat?.productMap;
|
||||
|
||||
let success = 0;
|
||||
for (const sub of subscriptions) {
|
||||
if (!sub.customerId) {
|
||||
this.logger.warn(`RevenueCat subscription missing customerId`, {
|
||||
subscription: sub,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const customerAlias = await this.rc.getCustomerAlias(sub.customerId);
|
||||
if (customerAlias && !customerAlias.includes(appUserId)) {
|
||||
this.logger.warn(`RevenueCat subscription customer alias mismatch`, {
|
||||
customerId: sub.customerId,
|
||||
customerAlias,
|
||||
appUserId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const mapping = resolveProductMapping(sub, productOverride);
|
||||
// ignore non-whitelisted and non-fallbackable products
|
||||
if (!mapping) continue;
|
||||
|
||||
const { status, deleteInstead, canceledAt, iapStore } =
|
||||
this.mapStatus(sub);
|
||||
const { status, deleteInstead, canceledAt, iapStore } = this.mapStatus(
|
||||
sub,
|
||||
overrideExpirationDate
|
||||
);
|
||||
|
||||
const rcExternalRef = this.pickExternalRef(event);
|
||||
const rcExternalRef = externalRef || this.pickExternalRef(event);
|
||||
// Upsert by unique (targetId, plan) for idempotency
|
||||
const start = sub.latestPurchaseDate || new Date();
|
||||
const end = overrideExpirationDate || sub.expirationDate || null;
|
||||
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
||||
|
||||
// Mutual exclusion: skip if Stripe already active for the same plan
|
||||
const conflict = await this.db.subscription.findFirst({
|
||||
where: {
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
provider: Provider.stripe,
|
||||
status: {
|
||||
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (conflict) {
|
||||
this.logger.warn(
|
||||
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
||||
);
|
||||
continue;
|
||||
if (conflict.provider === Provider.stripe) {
|
||||
this.logger.warn(
|
||||
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
||||
);
|
||||
continue;
|
||||
} else if (conflict.end && end && conflict.end > end) {
|
||||
this.logger.warn(
|
||||
`Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteInstead) {
|
||||
@@ -93,11 +179,6 @@ export class RevenueCatWebhookHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Upsert by unique (targetId, plan) for idempotency
|
||||
const start = sub.latestPurchaseDate || new Date();
|
||||
const end = sub.expirationDate || null;
|
||||
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
||||
|
||||
await this.db.subscription.upsert({
|
||||
where: {
|
||||
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
||||
@@ -153,6 +234,7 @@ export class RevenueCatWebhookHandler {
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
});
|
||||
success += 1;
|
||||
} else if (status !== SubscriptionStatus.PastDue) {
|
||||
// Do not emit canceled for PastDue (still within retry/grace window)
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
@@ -162,6 +244,7 @@ export class RevenueCatWebhookHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
return success > 0;
|
||||
}
|
||||
|
||||
private pickExternalRef(e?: RcEvent): string | null {
|
||||
@@ -172,7 +255,10 @@ export class RevenueCatWebhookHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private mapStatus(sub: Subscription): {
|
||||
private mapStatus(
|
||||
sub: Subscription,
|
||||
overrideExpirationDate?: Date
|
||||
): {
|
||||
status: SubscriptionStatus;
|
||||
iapStore: IapStore | null;
|
||||
deleteInstead: boolean;
|
||||
@@ -189,7 +275,7 @@ export class RevenueCatWebhookHandler {
|
||||
: null;
|
||||
|
||||
if (sub.isActive) {
|
||||
if (sub.isTrial) {
|
||||
if (sub.isTrial || overrideExpirationDate) {
|
||||
return {
|
||||
iapStore,
|
||||
status: SubscriptionStatus.Trialing,
|
||||
@@ -223,4 +309,90 @@ export class RevenueCatWebhookHandler {
|
||||
deleteInstead: true,
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob('nightly.revenuecat.subscription.refresh.anonymous')
|
||||
async onSubscriptionRefreshAnonymousUser(evt: {
|
||||
externalRef: string;
|
||||
startTime: number;
|
||||
}) {
|
||||
if (!this.config.payment.revenuecat?.enabled) return;
|
||||
if (Date.now() - evt.startTime > REFRESH_MAX_TIMES) {
|
||||
this.logger.warn(
|
||||
`RevenueCat subscription refresh timed out for externalRef ${evt.externalRef}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const subscriptions = await this.rc.getSubscriptionByExternalRef(
|
||||
evt.externalRef
|
||||
);
|
||||
let success = 0;
|
||||
if (subscriptions) {
|
||||
for (const sub of subscriptions) {
|
||||
if (!sub.customerId) {
|
||||
this.logger.warn(`RevenueCat subscription missing customerId`, {
|
||||
subscription: sub,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const customerAlias = await this.rc.getCustomerAlias(sub.customerId);
|
||||
if (customerAlias) {
|
||||
if (
|
||||
customerAlias.length === 0 ||
|
||||
customerAlias.length > 1 ||
|
||||
!customerAlias[0]
|
||||
) {
|
||||
this.logger.warn(
|
||||
`RevenueCat anonymous subscription has invalid customer alias`,
|
||||
{ customerId: sub.customerId, customerAlias }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const appUserId = customerAlias[0];
|
||||
const saved = await this.syncSubscription(
|
||||
appUserId,
|
||||
[sub],
|
||||
undefined,
|
||||
evt.externalRef
|
||||
);
|
||||
if (saved) success += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (success > 0) return;
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to fetch RC anonymous subscriptions by ${evt.externalRef}`,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < REFRESH_INTERVAL) {
|
||||
await sleep(REFRESH_INTERVAL - elapsed);
|
||||
}
|
||||
return JOB_SIGNAL.Retry;
|
||||
}
|
||||
|
||||
@OnJob('nightly.revenuecat.subscription.refresh')
|
||||
async onSubscriptionRefresh(evt: { userId: string; startTime: number }) {
|
||||
if (!this.config.payment.revenuecat?.enabled) return;
|
||||
if (Date.now() - evt.startTime > REFRESH_MAX_TIMES) {
|
||||
this.logger.warn(
|
||||
`RevenueCat subscription refresh timed out for user ${evt.userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const success = await this.syncAppUser(evt.userId);
|
||||
if (success) return;
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < REFRESH_INTERVAL) {
|
||||
await sleep(REFRESH_INTERVAL - elapsed);
|
||||
}
|
||||
return JOB_SIGNAL.Retry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,17 @@ declare global {
|
||||
event: RcEvent;
|
||||
};
|
||||
}
|
||||
|
||||
interface Jobs {
|
||||
'nightly.revenuecat.subscription.refresh': {
|
||||
userId: User['id'];
|
||||
startTime: number;
|
||||
};
|
||||
'nightly.revenuecat.subscription.refresh.anonymous': {
|
||||
externalRef: string;
|
||||
startTime: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface LookupKey {
|
||||
|
||||
@@ -1323,6 +1323,9 @@ type Mutation {
|
||||
removeWorkspaceEmbeddingFiles(fileId: String!, workspaceId: String!): Boolean!
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||
|
||||
"""Request to apply the subscription in advance"""
|
||||
requestApplySubscription(transactionId: String!): [SubscriptionType!]!
|
||||
|
||||
"""Resolve a comment or not"""
|
||||
resolveComment(input: CommentResolveInput!): Boolean!
|
||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user