mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf06722b7 | |||
| 925c95ce88 | |||
| 3098b3b14b | |||
| dd1cd77ca0 | |||
| d20dbfd6a2 | |||
| 41145961f9 | |||
| 1f2119e273 | |||
| 6e97aff7ba | |||
| 276b0db625 | |||
| bac346f304 | |||
| 9f33d37add | |||
| 3e42bbf4fa | |||
| b5e5f0708a | |||
| f96bf3dd24 | |||
| c53457691d | |||
| 103ad2a810 | |||
| ef4939009f | |||
| 0f5778ac89 | |||
| e9ef3c50c8 | |||
| 661d5d3831 | |||
| 6f55548661 | |||
| c39fa1ff2d | |||
| 3416de1e4d | |||
| d9cebdfc95 |
@@ -300,6 +300,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"description": "Configuration for permission module",
|
||||
"properties": {
|
||||
"readModel": {
|
||||
"type": "string",
|
||||
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
|
||||
"default": "projection"
|
||||
},
|
||||
"fallbackLegacyLoader": {
|
||||
"type": "boolean",
|
||||
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"type": "object",
|
||||
"description": "Configuration for storages module",
|
||||
|
||||
Generated
+5
-15
@@ -143,7 +143,6 @@ dependencies = [
|
||||
"mermaid-rs-renderer",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"typst",
|
||||
@@ -237,6 +236,7 @@ dependencies = [
|
||||
"rand 0.9.4",
|
||||
"rayon",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -246,6 +246,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"v_htmlescape",
|
||||
"webpki-roots",
|
||||
"y-octo",
|
||||
]
|
||||
|
||||
@@ -3747,9 +3748,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "llm_adapter"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca30267ba36e247d1ff7a916a03db2ceb1de7f0bfcab7250cde006cdda68c19"
|
||||
checksum = "332397a6ccde5ac47fc32b29a2eed447135eb4ff6fd05ffb88dfe937ea9c8211"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"jsonschema",
|
||||
@@ -6225,7 +6226,6 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -6235,7 +6235,6 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8048,7 +8047,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8410,15 +8409,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
|
||||
+3
-4
@@ -16,10 +16,10 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
affine_common = { path = "./packages/common/native" }
|
||||
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
|
||||
ahash = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
arbitrary = { version = "1.3", features = ["derive"] }
|
||||
assert-json-diff = "2.0"
|
||||
@@ -40,6 +40,7 @@ resolver = "3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
hex = "0.4"
|
||||
homedir = "0.3"
|
||||
image = { version = "0.25.9", default-features = false, features = [
|
||||
"bmp",
|
||||
@@ -81,6 +82,7 @@ resolver = "3"
|
||||
ogg = "0.9"
|
||||
once_cell = "1"
|
||||
ordered-float = "5"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
parking_lot = "0.12"
|
||||
path-ext = "0.1.2"
|
||||
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
|
||||
@@ -99,8 +101,6 @@ resolver = "3"
|
||||
screencapturekit = "0.3"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
sha2 = "0.10"
|
||||
sha3 = "0.10"
|
||||
smol_str = "0.3"
|
||||
@@ -110,7 +110,6 @@ resolver = "3"
|
||||
"migrate",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"tls-rustls",
|
||||
] }
|
||||
strum_macros = "0.27.0"
|
||||
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
|
||||
|
||||
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
|
||||
dataSource: this.dataSource,
|
||||
headerWidget: this.headerWidget,
|
||||
clipboard: this.std.clipboard,
|
||||
dnd: this.std.dnd,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
DatabaseKanbanViewIcon,
|
||||
DatabaseTableViewIcon,
|
||||
TodayIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertDatabaseBlockCommand } from '../commands';
|
||||
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Calendar View',
|
||||
description: 'Display items by date in a calendar.',
|
||||
searchAlias: ['database', 'calendar'],
|
||||
icon: TodayIcon(),
|
||||
group: '7_Database@1',
|
||||
when: ({ model }) =>
|
||||
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertDatabaseBlockCommand, {
|
||||
viewType: viewPresets.calendarViewMeta.type,
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedDatabaseBlockId }) => {
|
||||
if (insertedDatabaseBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:database',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Kanban View',
|
||||
description: 'Visualize data in a dashboard.',
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type SingleView,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
|
||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { Rect } from '@blocksuite/global/gfx';
|
||||
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
config
|
||||
);
|
||||
});
|
||||
this.std.provider
|
||||
.getAll(CalendarExternalSourceProvider)
|
||||
.forEach(source => {
|
||||
dataSource.serviceSet(
|
||||
CalendarExternalSourceProvider(source.id),
|
||||
source
|
||||
);
|
||||
});
|
||||
});
|
||||
const id = currentViewStorage.getCurrentView(this.model.id);
|
||||
if (id && dataSource.viewManager.viewGet(id)) {
|
||||
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
calendar: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
});
|
||||
|
||||
private readonly viewSelection$ = computed(() => {
|
||||
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
headerWidget: this.headerWidget,
|
||||
onDrag: this.onDrag,
|
||||
clipboard: this.std.clipboard,
|
||||
dnd: this.std.dnd,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
export const databaseBlockViews: ViewMeta[] = [
|
||||
viewPresets.tableViewMeta,
|
||||
viewPresets.kanbanViewMeta,
|
||||
viewPresets.calendarViewMeta,
|
||||
];
|
||||
|
||||
export const databaseBlockViewMap = Object.fromEntries(
|
||||
|
||||
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.inputRef.select();
|
||||
if (!this.data.disableAutoFocus) {
|
||||
this.inputRef.select();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -223,6 +225,7 @@ export const menuInputItems = {
|
||||
onComplete?: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
disableAutoFocus?: boolean;
|
||||
class?: string;
|
||||
style?: Readonly<StyleInfo>;
|
||||
}) =>
|
||||
@@ -237,6 +240,7 @@ export const menuInputItems = {
|
||||
onComplete: config.onComplete,
|
||||
onChange: config.onChange,
|
||||
onBlur: config.onBlur,
|
||||
disableAutoFocus: config.disableAutoFocus,
|
||||
};
|
||||
const style = styleMap({
|
||||
display: 'flex',
|
||||
|
||||
@@ -111,8 +111,10 @@ export class MenuComponent
|
||||
}
|
||||
const onBack = this.menu.options.title?.onBack;
|
||||
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
|
||||
this.menu.close();
|
||||
onBack(this.menu);
|
||||
const result = onBack(this.menu);
|
||||
if (result !== false) {
|
||||
this.menu.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
@@ -214,8 +216,10 @@ export class MenuComponent
|
||||
${title.onBack
|
||||
? html` <div
|
||||
@click="${() => {
|
||||
title.onBack?.(this.menu);
|
||||
this.menu.close();
|
||||
const result = title.onBack?.(this.menu);
|
||||
if (result !== false) {
|
||||
this.menu.close();
|
||||
}
|
||||
}}"
|
||||
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
|
||||
style="display:flex;"
|
||||
|
||||
@@ -15,7 +15,7 @@ export type MenuOptions = {
|
||||
onClose?: () => void;
|
||||
title?: {
|
||||
text: string;
|
||||
onBack?: (menu: Menu) => void;
|
||||
onBack?: (menu: Menu) => boolean | void;
|
||||
onClose?: () => void;
|
||||
postfix?: () => TemplateResult;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type CalendarEntry,
|
||||
createCalendarMonthLayout,
|
||||
getCalendarDayContentSlots,
|
||||
getCalendarVisibleMonthRange,
|
||||
} from '../view-presets/calendar/index.js';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||
|
||||
describe('calendar month layout', () => {
|
||||
it('buckets single day entries', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Task',
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('splits range external entries across weeks', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Trip',
|
||||
startAt: day('2026-05-09'),
|
||||
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 1, startIndex: 6, span: 1 },
|
||||
{ weekIndex: 2, startIndex: 0, span: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats all-day external midnight end as exclusive', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'All day',
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: day('2026-05-16'),
|
||||
allDay: true,
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('treats row midnight end date as inclusive', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Task',
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: day('2026-05-16'),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 2, startIndex: 5, span: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clips range entries to visible month range', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Long trip',
|
||||
startAt: day('2026-04-01'),
|
||||
endAt: day('2026-06-30'),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments[0]).toMatchObject({
|
||||
weekIndex: 0,
|
||||
startIndex: 0,
|
||||
span: 7,
|
||||
});
|
||||
expect(layout.segments.at(-1)).toMatchObject({
|
||||
weekIndex: layout.weeks.length - 1,
|
||||
startIndex: 0,
|
||||
span: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('pads month view to full weeks', () => {
|
||||
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [],
|
||||
});
|
||||
|
||||
expect(new Date(range.from).getDay()).toBe(0);
|
||||
expect(new Date(range.to).getDay()).toBe(6);
|
||||
expect(layout.days).toHaveLength(layout.weeks.length * 7);
|
||||
});
|
||||
|
||||
it('keeps day buckets on local midnight across DST boundaries', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'DST task',
|
||||
startAt: day('2026-03-09'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-03-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.every(item => {
|
||||
const date = new Date(item.date);
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
);
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-03-09'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('keeps range segment offsets across DST boundaries', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'DST range',
|
||||
startAt: day('2026-03-09'),
|
||||
endAt: new Date('2026-03-10T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-03-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 1, startIndex: 1, span: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps all same-day entries in the day bucket', () => {
|
||||
const entries = Array.from(
|
||||
{ length: 4 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'row',
|
||||
id: `database:row-${index}`,
|
||||
sourceId: 'database',
|
||||
rowId: `row-${index}`,
|
||||
title: `Task ${index}`,
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
}) satisfies CalendarEntry
|
||||
);
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('assigns each overlapping range segment to its own slot', () => {
|
||||
const entries: CalendarEntry[] = [
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'external',
|
||||
id: `external:full-${index}`,
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: `full-${index}`,
|
||||
title: `Full ${index}`,
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: new Date('2026-05-17T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
}) as const
|
||||
),
|
||||
{
|
||||
kind: 'external',
|
||||
id: 'external:short',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: 'short',
|
||||
title: 'Short',
|
||||
startAt: day('2026-05-18'),
|
||||
endAt: new Date('2026-05-19T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
},
|
||||
];
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
|
||||
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
|
||||
|
||||
expect(getCalendarDayContentSlots(may15)).toBe(3);
|
||||
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
|
||||
expect(getCalendarDayContentSlots(may18)).toBe(1);
|
||||
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('counts segment and same-day slots for drag preview placement', () => {
|
||||
const entries: CalendarEntry[] = [
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'external',
|
||||
id: `external:range-${index}`,
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: `range-${index}`,
|
||||
title: `Range ${index}`,
|
||||
startAt: day('2026-05-08'),
|
||||
endAt: new Date('2026-05-09T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
}) as const
|
||||
),
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:moving',
|
||||
sourceId: 'database',
|
||||
rowId: 'moving',
|
||||
title: 'Moving',
|
||||
startAt: day('2026-05-06'),
|
||||
endAt: new Date('2026-05-08T12:00:00').getTime(),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
},
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:single',
|
||||
sourceId: 'database',
|
||||
rowId: 'single',
|
||||
title: 'Single',
|
||||
startAt: day('2026-05-08'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
},
|
||||
];
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
|
||||
|
||||
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
|
||||
});
|
||||
|
||||
it('splits row range entries across weeks with continuation metadata', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Project',
|
||||
startAt: day('2026-05-09'),
|
||||
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{
|
||||
weekIndex: 1,
|
||||
startIndex: 6,
|
||||
span: 1,
|
||||
startsBeforeWeek: false,
|
||||
endsAfterWeek: true,
|
||||
},
|
||||
{
|
||||
weekIndex: 2,
|
||||
startIndex: 0,
|
||||
span: 3,
|
||||
startsBeforeWeek: true,
|
||||
endsAfterWeek: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips range entries completely outside the visible month range', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:outside',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: 'outside',
|
||||
title: 'Outside',
|
||||
startAt: day('2026-06-10'),
|
||||
endAt: day('2026-06-12'),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toEqual([]);
|
||||
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,812 @@
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DataSource } from '../core/data-source/base.js';
|
||||
import {
|
||||
CalendarSingleView,
|
||||
type CalendarStoredViewData,
|
||||
calendarViewModel,
|
||||
} from '../view-presets/calendar/index.js';
|
||||
import {
|
||||
formatEntryTime,
|
||||
openCalendarEntry,
|
||||
} from '../view-presets/calendar/pc/actions.js';
|
||||
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
|
||||
import { viewConverts } from '../view-presets/convert.js';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||
|
||||
const createCalendarView = (options?: {
|
||||
startColumnId?: string;
|
||||
endColumnId?: string;
|
||||
datePropertyType?: string;
|
||||
rows?: string[];
|
||||
filterValue?: string;
|
||||
titleValue?: unknown;
|
||||
linkedDocTitles?: Record<string, string>;
|
||||
visiblePropertyIds?: string[];
|
||||
externalFactories?: Map<unknown, unknown>;
|
||||
}) => {
|
||||
const rows = signal(options?.rows ?? ['row-1']);
|
||||
const columns = signal(['title', 'date', 'end-date', 'status']);
|
||||
const viewData = signal<CalendarStoredViewData>({
|
||||
id: 'view-1',
|
||||
name: 'Calendar',
|
||||
mode: 'calendar',
|
||||
filter: options?.filterValue
|
||||
? {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: { type: 'ref', name: 'status' },
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: options.filterValue }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
date: {
|
||||
startColumnId: options?.startColumnId,
|
||||
endColumnId: options?.endColumnId,
|
||||
},
|
||||
card: {
|
||||
titleColumnId: 'title',
|
||||
visiblePropertyIds: options?.visiblePropertyIds ?? [],
|
||||
},
|
||||
sources: {
|
||||
workspaceCalendar: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const values = new Map<string, unknown>([
|
||||
['row-1:date', day('2026-05-15')],
|
||||
['row-1:end-date', day('2026-05-17')],
|
||||
['row-1:status', 'Done'],
|
||||
['row-1:title', options?.titleValue ?? 'Task'],
|
||||
['row-2:date', day('2026-05-16')],
|
||||
['row-2:end-date', day('2026-05-14')],
|
||||
['row-2:status', 'Todo'],
|
||||
['row-2:title', 'Hidden'],
|
||||
]);
|
||||
const types = new Map<string, string>([
|
||||
['title', 'title'],
|
||||
['date', options?.datePropertyType ?? 'date'],
|
||||
['end-date', 'date'],
|
||||
['status', 'text'],
|
||||
]);
|
||||
|
||||
const dataSource = {
|
||||
rows$: rows,
|
||||
properties$: columns,
|
||||
readonly$: signal(false),
|
||||
featureFlags$: signal({ enable_table_virtual_scroll: false }),
|
||||
provider: {
|
||||
getAll: () => options?.externalFactories ?? new Map(),
|
||||
},
|
||||
viewDataGet: () => viewData.value,
|
||||
viewDataUpdate: (
|
||||
_id: string,
|
||||
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
|
||||
) => {
|
||||
viewData.value = { ...viewData.value, ...updater(viewData.value) };
|
||||
},
|
||||
cellValueGet: (rowId: string, propertyId: string) =>
|
||||
values.get(`${rowId}:${propertyId}`),
|
||||
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
|
||||
values.set(`${rowId}:${propertyId}`, value);
|
||||
},
|
||||
rowAdd: () => {
|
||||
const rowId = `row-${rows.value.length + 1}`;
|
||||
rows.value = [...rows.value, rowId];
|
||||
return rowId;
|
||||
},
|
||||
propertyTypeGet: (propertyId: string) => types.get(propertyId),
|
||||
propertyNameGet: (propertyId: string) => propertyId,
|
||||
propertyDataGet: () => ({}),
|
||||
propertyReadonlyGet: () => false,
|
||||
serviceGet: (key: unknown) => {
|
||||
if (key !== DocDisplayMetaProvider) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: (pageId: string, referenceInfo?: { title?: string }) =>
|
||||
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
|
||||
};
|
||||
},
|
||||
propertyMetaGet: (type: string) => ({
|
||||
type,
|
||||
config: {
|
||||
rawValue: {
|
||||
toJson: ({ value }: { value: unknown }) => {
|
||||
const deltas =
|
||||
typeof value === 'object' && value != null && 'deltas$' in value
|
||||
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||
: undefined;
|
||||
if (!Array.isArray(deltas)) {
|
||||
return value;
|
||||
}
|
||||
return deltas
|
||||
.map(delta => {
|
||||
const item = delta as {
|
||||
insert?: unknown;
|
||||
attributes?: {
|
||||
reference?: {
|
||||
type?: string;
|
||||
pageId?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const pageId = item.attributes?.reference?.pageId;
|
||||
if (
|
||||
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||
typeof pageId === 'string'
|
||||
) {
|
||||
return (
|
||||
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
|
||||
);
|
||||
}
|
||||
return item.insert ?? '';
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
fromJson: ({ value }: { value: unknown }) => value,
|
||||
toString: ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value : '',
|
||||
},
|
||||
jsonValue: {
|
||||
schema: {
|
||||
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||
},
|
||||
isEmpty: () => false,
|
||||
type: () => undefined,
|
||||
},
|
||||
},
|
||||
renderer: {},
|
||||
}),
|
||||
propertyAdd: () => {
|
||||
columns.value = [...columns.value, 'created-date'];
|
||||
types.set('created-date', 'date');
|
||||
return 'created-date';
|
||||
},
|
||||
propertyCanDelete: () => true,
|
||||
propertyCanDuplicate: () => true,
|
||||
propertyTypeCanSet: () => true,
|
||||
} as unknown as DataSource;
|
||||
const manager = {
|
||||
dataSource,
|
||||
readonly$: signal(false),
|
||||
};
|
||||
return {
|
||||
view: new CalendarSingleView(manager as any, 'view-1'),
|
||||
viewData,
|
||||
values,
|
||||
types,
|
||||
columns,
|
||||
};
|
||||
};
|
||||
|
||||
describe('CalendarSingleView', () => {
|
||||
it('creates default view data without selecting a start date', () => {
|
||||
const data = calendarViewModel.model.defaultData({
|
||||
dataSource: {
|
||||
properties$: signal(['title', 'date']),
|
||||
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(data.date).toEqual({});
|
||||
expect(data.card).toEqual({
|
||||
titleColumnId: 'title',
|
||||
visiblePropertyIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('enters setup state without a start date property', () => {
|
||||
const { view } = createCalendarView();
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('enters setup state when start date column is not date', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
datePropertyType: 'text',
|
||||
});
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('enters setup state after date property deletion', () => {
|
||||
const { view, columns } = createCalendarView({ startColumnId: 'date' });
|
||||
|
||||
columns.value = ['title', 'status'];
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('creates row entries after filtering rows', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
rows: ['row-1', 'row-2'],
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
|
||||
});
|
||||
|
||||
it('updates entry date after row date value changes', () => {
|
||||
const { view, values } = createCalendarView({ startColumnId: 'date' });
|
||||
|
||||
values.set('row-1:date', day('2026-05-20'));
|
||||
|
||||
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
|
||||
});
|
||||
|
||||
it('creates row range entries and falls back when end date is invalid', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
rows: ['row-1', 'row-2'],
|
||||
});
|
||||
|
||||
expect(
|
||||
view.rowEntries$.value.map(entry => [
|
||||
entry.rowId,
|
||||
entry.startAt,
|
||||
entry.endAt,
|
||||
])
|
||||
).toEqual([
|
||||
['row-1', day('2026-05-15'), day('2026-05-17')],
|
||||
['row-2', day('2026-05-16'), undefined],
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
|
||||
});
|
||||
|
||||
it('moves row range while preserving duration', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
|
||||
view.moveRowToDate('row-1', day('2026-05-20'));
|
||||
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
|
||||
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
|
||||
});
|
||||
|
||||
it('resizes row range without crossing start and end', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
|
||||
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
|
||||
|
||||
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
|
||||
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
|
||||
});
|
||||
|
||||
it('creates a row with default filter values and target date', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
const rowId = view.createRowOnDate(day('2026-05-25'));
|
||||
|
||||
expect(rowId).toBe('row-2');
|
||||
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||
expect(values.get('row-2:status')).toBe('Done');
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a dated linked-doc row', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
|
||||
const title = values.get('row-2:title') as
|
||||
| { toDelta?: () => unknown[] }
|
||||
| undefined;
|
||||
|
||||
expect(rowId).toBe('row-2');
|
||||
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||
expect(values.get('row-2:status')).toBe('Done');
|
||||
expect(title?.toDelta?.()).toEqual([
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('dismisses the empty month hint on the current calendar view', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(false);
|
||||
|
||||
view.dismissEmptyMonthHint();
|
||||
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||
expect('ui' in viewData.value && viewData.value.ui).toEqual({
|
||||
emptyMonthHintDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates workspace calendar settings when legacy view data has no sources', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
viewData.value = {
|
||||
...viewData.value,
|
||||
sources: undefined as unknown as CalendarStoredViewData['sources'],
|
||||
};
|
||||
|
||||
view.setWorkspaceCalendarEnabled(false);
|
||||
|
||||
expect(viewData.value.sources.workspaceCalendar).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('enters setup state when legacy view data has no date config', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
viewData.value = {
|
||||
...viewData.value,
|
||||
date: undefined as unknown as CalendarStoredViewData['date'],
|
||||
};
|
||||
|
||||
expect(view.dateMapping$.value).toEqual({
|
||||
status: 'setup',
|
||||
propertyId: undefined,
|
||||
});
|
||||
expect(view.endDateMapping$.value).toEqual({
|
||||
status: 'setup',
|
||||
propertyId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('generates card properties from visible property ids', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
visiblePropertyIds: ['status'],
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
|
||||
{
|
||||
propertyId: 'status',
|
||||
value: 'Done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses single linked doc id from title cell', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Linked doc title',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: 'Doc',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Linked doc title', linkedDoc: true },
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
|
||||
});
|
||||
|
||||
it('uses normal title text for multiple linked doc titles', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Doc 1',
|
||||
'doc-2': 'Doc 2',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: 'Doc 1',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'Doc 2',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Doc 1', linkedDoc: true },
|
||||
{ text: 'Doc 2', linkedDoc: true },
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
|
||||
});
|
||||
|
||||
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Doc 1',
|
||||
'doc-2': 'Doc 2',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Doc 1', linkedDoc: true },
|
||||
{ text: 'Doc 2', linkedDoc: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges linked doc placeholders with the following plain title text', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: { type: 'LinkedPage', pageId: 'doc-1' },
|
||||
},
|
||||
},
|
||||
{ insert: 'How to use folder and Tags' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'How to use folder and Tags', linkedDoc: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates date mapping through setup APIs', () => {
|
||||
const { view, viewData, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
|
||||
view.moveRowToDate('row-1', day('2026-05-21'));
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
|
||||
|
||||
view.setDateColumn('date');
|
||||
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||
'date'
|
||||
);
|
||||
|
||||
expect(view.createDateColumn()).toBe('created-date');
|
||||
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||
'created-date'
|
||||
);
|
||||
});
|
||||
|
||||
it('aggregates external source entries without mutating view data', async () => {
|
||||
const externalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'source',
|
||||
externalId: '1',
|
||||
title: 'External',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const anotherExternalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:2',
|
||||
sourceId: 'another-source',
|
||||
externalId: '2',
|
||||
title: 'Another external',
|
||||
startAt: day('2026-05-16'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries: () => [externalEntry],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
'another-source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'another-source',
|
||||
getEntries: () => Promise.resolve([anotherExternalEntry]),
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const viewDataBefore = JSON.stringify(viewData.value);
|
||||
|
||||
await expect(
|
||||
view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
})
|
||||
).resolves.toEqual([externalEntry, anotherExternalEntry]);
|
||||
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
|
||||
});
|
||||
|
||||
it('keeps successful external entries when another source fails', async () => {
|
||||
const externalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'source',
|
||||
externalId: '1',
|
||||
title: 'External',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries: () => [externalEntry],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
'failing-source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'failing-source',
|
||||
getEntries: () => Promise.reject(new Error('denied')),
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
})
|
||||
).resolves.toEqual([externalEntry]);
|
||||
});
|
||||
|
||||
it('does not let stale external entry loads overwrite newer entries', async () => {
|
||||
const oldEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:old',
|
||||
sourceId: 'source',
|
||||
externalId: 'old',
|
||||
title: 'Old',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const newEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:new',
|
||||
sourceId: 'source',
|
||||
externalId: 'new',
|
||||
title: 'New',
|
||||
startAt: day('2026-06-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
let resolveOld!: (entries: [typeof oldEntry]) => void;
|
||||
let resolveNew!: (entries: [typeof newEntry]) => void;
|
||||
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
const newRequest = new Promise<[typeof newEntry]>(resolve => {
|
||||
resolveNew = resolve;
|
||||
});
|
||||
const getEntries = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(oldRequest)
|
||||
.mockReturnValueOnce(newRequest);
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries,
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const firstLoad = view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
});
|
||||
const secondLoad = view.loadExternalEntries({
|
||||
from: day('2026-06-01'),
|
||||
to: day('2026-06-30'),
|
||||
});
|
||||
|
||||
resolveNew([newEntry]);
|
||||
await expect(secondLoad).resolves.toEqual([newEntry]);
|
||||
expect(
|
||||
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||
).toEqual([newEntry]);
|
||||
|
||||
resolveOld([oldEntry]);
|
||||
await expect(firstLoad).resolves.toEqual([oldEntry]);
|
||||
expect(
|
||||
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||
).toEqual([newEntry]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar entry actions', () => {
|
||||
it('formats external event popover time ranges with end time', () => {
|
||||
const label = formatEntryTime({
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Planning',
|
||||
startAt: new Date('2026-05-15T10:00:00').getTime(),
|
||||
endAt: new Date('2026-05-15T11:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
});
|
||||
|
||||
expect(label).toContain(' - ');
|
||||
expect(label).toContain('2026');
|
||||
});
|
||||
|
||||
it('opens row entries through the detail panel hook', () => {
|
||||
const openDetailPanel = vi.fn();
|
||||
const { view } = createCalendarView({ startColumnId: 'date' });
|
||||
const target = {} as HTMLElement;
|
||||
|
||||
openCalendarEntry(
|
||||
{ openDetailPanel } as any,
|
||||
view,
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Doc',
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
},
|
||||
target
|
||||
);
|
||||
|
||||
expect(openDetailPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view, rowId: 'row-1' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar view converts', () => {
|
||||
it('converts header/card semantics without date mapping', () => {
|
||||
const tableToCalendar = viewConverts.find(
|
||||
convert => convert.from === 'table' && convert.to === 'calendar'
|
||||
);
|
||||
const calendarToKanban = viewConverts.find(
|
||||
convert => convert.from === 'calendar' && convert.to === 'kanban'
|
||||
);
|
||||
const filter = { type: 'group', op: 'and', conditions: [] } as const;
|
||||
const sort = { columns: [] };
|
||||
const header = { titleColumn: 'title' };
|
||||
|
||||
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
|
||||
filter,
|
||||
sort,
|
||||
card: { titleColumnId: 'title', visiblePropertyIds: [] },
|
||||
});
|
||||
expect(
|
||||
calendarToKanban?.convert({
|
||||
filter,
|
||||
sort,
|
||||
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
|
||||
date: { startColumnId: 'date' },
|
||||
} as any)
|
||||
).toEqual({ filter, sort, header });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar dnd payload', () => {
|
||||
it('reads calendar entry payloads from blocksuite dnd data', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({
|
||||
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
|
||||
})
|
||||
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
|
||||
});
|
||||
|
||||
it('normalizes affine doc entities for future document drops', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({
|
||||
entity: { type: 'doc', id: 'doc-1' },
|
||||
})
|
||||
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||
});
|
||||
|
||||
it('reads document payloads from blocksuite dnd data', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
|
||||
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type Clipboard,
|
||||
type DndController,
|
||||
type EventName,
|
||||
ShadowlessElement,
|
||||
type UIEventHandler,
|
||||
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
|
||||
|
||||
export type DataViewRendererConfig = {
|
||||
clipboard: Clipboard;
|
||||
dnd?: DndController;
|
||||
onDrag?: (evt: MouseEvent, id: string) => () => void;
|
||||
notification: {
|
||||
toast: (message: string) => void;
|
||||
|
||||
@@ -2,15 +2,10 @@ import {
|
||||
dropdownSubMenuMiddleware,
|
||||
menu,
|
||||
type MenuConfig,
|
||||
type MenuOptions,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
|
||||
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
export const selectGroupByProperty = (
|
||||
export const buildGroupSelectItems = (
|
||||
group: GroupTrait,
|
||||
ops?: {
|
||||
onSelect?: (id?: string) => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
): MenuOptions => {
|
||||
onSelect: (id?: string) => void
|
||||
): MenuConfig[] => {
|
||||
const view = group.view;
|
||||
return {
|
||||
onClose: ops?.onClose,
|
||||
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
|
||||
items: [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
.filter(property => {
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(view.manager.dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
})
|
||||
.map<MenuConfig>(property => {
|
||||
return menu.action({
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === property.id,
|
||||
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(property.id);
|
||||
ops?.onSelect?.(property.id);
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
return [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
.filter(property => {
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(view.manager.dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
})
|
||||
.map<MenuConfig>(property =>
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === property.id,
|
||||
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
ops?.onSelect?.();
|
||||
group.changeGroup(property.id);
|
||||
onSelect(property.id);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
})
|
||||
),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
onSelect(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export const popSelectGroupByProperty = (
|
||||
target: PopupTarget,
|
||||
export const buildGroupSettingItems = (
|
||||
group: GroupTrait,
|
||||
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
const handler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, ops),
|
||||
middleware,
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
export const popGroupSetting = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
onBack: () => void,
|
||||
onClose?: () => void,
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
onGroupByClick: () => void,
|
||||
onGroupRemoved?: () => void
|
||||
): MenuConfig[] => {
|
||||
const view = group.view;
|
||||
const gProp = group.property$.value;
|
||||
if (!gProp) return;
|
||||
if (!gProp) return [];
|
||||
const type = gProp.type$.value;
|
||||
if (!type) return;
|
||||
|
||||
if (!type) return [];
|
||||
const icon = gProp.icon;
|
||||
const menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Group',
|
||||
onBack,
|
||||
onClose,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const subHandler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, {
|
||||
onSelect: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onBack: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onClose,
|
||||
}),
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
return [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
onGroupByClick();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name === key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
@@ -462,179 +452,118 @@ export const popGroupSetting = (
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config
|
||||
.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name ===
|
||||
key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value
|
||||
? 'Oldest first'
|
||||
: 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menuObj => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menuObj.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menu.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
onGroupRemoved?.();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
middleware,
|
||||
});
|
||||
menuHandler.menu.menuElement.style.minHeight = '550px';
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
|
||||
import { type DeltaInsert, Text } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
import { evalFilter } from '../../core/filter/eval.js';
|
||||
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
|
||||
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import { emptyFilterGroup } from '../../core/filter/utils.js';
|
||||
import { fromJson } from '../../core/property/utils';
|
||||
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||
import { type Row, RowBase } from '../../core/view-manager/row.js';
|
||||
import {
|
||||
type SingleView,
|
||||
SingleViewBase,
|
||||
} from '../../core/view-manager/single-view.js';
|
||||
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import { getCalendarExternalSources } from './source.js';
|
||||
import type {
|
||||
CalendarEntry,
|
||||
CalendarEntryRange,
|
||||
CalendarExternalEntry,
|
||||
CalendarExternalSource,
|
||||
CalendarRowEntry,
|
||||
CalendarStoredViewData,
|
||||
CalendarTitleSegment,
|
||||
} from './types.js';
|
||||
|
||||
export type CalendarDateMapping =
|
||||
| {
|
||||
status: 'ready';
|
||||
propertyId: string;
|
||||
}
|
||||
| {
|
||||
status: 'setup';
|
||||
propertyId?: string;
|
||||
};
|
||||
|
||||
const getStartColumnId = (data?: CalendarStoredViewData) =>
|
||||
data?.date?.startColumnId;
|
||||
|
||||
const getEndColumnId = (data?: CalendarStoredViewData) => {
|
||||
return data?.date?.endColumnId;
|
||||
};
|
||||
|
||||
const getDateData = (data: CalendarStoredViewData) => ({
|
||||
...data.date,
|
||||
startColumnId: getStartColumnId(data),
|
||||
});
|
||||
|
||||
const getCardData = (data?: CalendarStoredViewData) => {
|
||||
if (data) {
|
||||
return data.card;
|
||||
}
|
||||
return {
|
||||
visiblePropertyIds: [],
|
||||
};
|
||||
};
|
||||
|
||||
const toTimestamp = (date: number | Date) =>
|
||||
date instanceof Date ? date.getTime() : date;
|
||||
|
||||
const isValidTimestamp = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const createLinkedDocTitle = (docId: string) => {
|
||||
const text = new Text<AffineTextAttributes>();
|
||||
new Doc().getMap('root').set('text', text.yText);
|
||||
text.applyDelta([
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
|
||||
},
|
||||
] satisfies DeltaInsert<AffineTextAttributes>[]);
|
||||
return text;
|
||||
};
|
||||
|
||||
const getTitleDeltas = (value: unknown) =>
|
||||
typeof value === 'object' && value != null && 'deltas$' in value
|
||||
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||
: undefined;
|
||||
|
||||
const getTitleSegments = (
|
||||
value: unknown,
|
||||
title: string,
|
||||
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
|
||||
): CalendarTitleSegment[] | undefined => {
|
||||
const deltas = getTitleDeltas(value);
|
||||
if (!Array.isArray(deltas)) {
|
||||
return;
|
||||
}
|
||||
const segments = deltas.flatMap(delta => {
|
||||
const item = delta as {
|
||||
insert?: unknown;
|
||||
attributes?: {
|
||||
reference?: {
|
||||
type?: string;
|
||||
pageId?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const linkedDoc =
|
||||
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||
typeof item.attributes.reference.pageId === 'string';
|
||||
const referenceTitle = item.attributes?.reference?.title;
|
||||
const resolvedLinkedDocTitle =
|
||||
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
|
||||
? getLinkedDocTitle?.(
|
||||
item.attributes.reference.pageId,
|
||||
typeof referenceTitle === 'string' ? referenceTitle : undefined
|
||||
)
|
||||
: undefined;
|
||||
const text =
|
||||
resolvedLinkedDocTitle ||
|
||||
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
|
||||
? referenceTitle
|
||||
: typeof item.insert === 'string'
|
||||
? item.insert.trim()
|
||||
: '');
|
||||
if (linkedDoc) {
|
||||
return {
|
||||
text,
|
||||
linkedDoc,
|
||||
};
|
||||
}
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
text,
|
||||
};
|
||||
});
|
||||
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
|
||||
(result, segment) => {
|
||||
const previous = result.at(-1);
|
||||
if (
|
||||
previous?.linkedDoc &&
|
||||
!previous.text &&
|
||||
!segment.linkedDoc &&
|
||||
segment.text
|
||||
) {
|
||||
previous.text = segment.text;
|
||||
return result;
|
||||
}
|
||||
result.push(segment);
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
|
||||
return;
|
||||
}
|
||||
if (!normalizedSegments.some(segment => segment.text)) {
|
||||
return title
|
||||
? [...normalizedSegments, { text: title }]
|
||||
: normalizedSegments;
|
||||
}
|
||||
return normalizedSegments;
|
||||
};
|
||||
|
||||
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
|
||||
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
|
||||
|
||||
private externalEntriesRequestId = 0;
|
||||
|
||||
propertiesRaw$ = computed(() => {
|
||||
return this.dataSource.properties$.value.map(id =>
|
||||
this.propertyGetOrCreate(id)
|
||||
);
|
||||
});
|
||||
|
||||
properties$ = this.propertiesRaw$;
|
||||
|
||||
detailProperties$ = computed(() => {
|
||||
return this.propertiesRaw$.value.filter(
|
||||
property => property.type$.value !== 'title'
|
||||
);
|
||||
});
|
||||
|
||||
private readonly filter$ = computed(() => {
|
||||
return this.data$.value?.filter ?? emptyFilterGroup;
|
||||
});
|
||||
|
||||
private readonly sortList$ = computed(() => {
|
||||
return this.data$.value?.sort;
|
||||
});
|
||||
|
||||
emptyMonthHintDismissed$ = computed(() => {
|
||||
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
|
||||
});
|
||||
|
||||
private readonly sortManager = this.traitSet(
|
||||
sortTraitKey,
|
||||
new SortManager(this.sortList$, this, {
|
||||
setSortList: sortList => {
|
||||
this.dataUpdate(data => ({
|
||||
sort: {
|
||||
...data.sort,
|
||||
...sortList,
|
||||
},
|
||||
}));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
filterTrait = this.traitSet(
|
||||
filterTraitKey,
|
||||
new FilterTrait(this.filter$, this, {
|
||||
filterSet: (filter: FilterGroup) => {
|
||||
this.dataUpdate(() => ({ filter }));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
mainProperties$ = computed(() => {
|
||||
const card = getCardData(this.data$.value);
|
||||
return {
|
||||
titleColumn:
|
||||
card.titleColumnId ??
|
||||
this.propertiesRaw$.value.find(
|
||||
property => property.type$.value === 'title'
|
||||
)?.id,
|
||||
};
|
||||
});
|
||||
|
||||
readonly$ = computed(() => {
|
||||
return this.manager.readonly$.value;
|
||||
});
|
||||
|
||||
dateProperties$ = computed(() => {
|
||||
return this.propertiesRaw$.value.filter(
|
||||
property => property.type$.value === 'date'
|
||||
);
|
||||
});
|
||||
|
||||
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||
const propertyId = getStartColumnId(this.data$.value);
|
||||
if (
|
||||
propertyId &&
|
||||
this.dataSource.properties$.value.includes(propertyId) &&
|
||||
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||
) {
|
||||
return {
|
||||
status: 'ready',
|
||||
propertyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'setup',
|
||||
propertyId,
|
||||
};
|
||||
});
|
||||
|
||||
startDateMapping$ = this.dateMapping$;
|
||||
|
||||
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||
const propertyId = getEndColumnId(this.data$.value);
|
||||
if (
|
||||
propertyId &&
|
||||
this.dataSource.properties$.value.includes(propertyId) &&
|
||||
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||
) {
|
||||
return {
|
||||
status: 'ready',
|
||||
propertyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'setup',
|
||||
propertyId,
|
||||
};
|
||||
});
|
||||
|
||||
private readonly visibleCardProperties$ = computed(() => {
|
||||
const card = getCardData(this.data$.value);
|
||||
const visiblePropertyIds = card.visiblePropertyIds ?? [];
|
||||
const titleColumn = card.titleColumnId;
|
||||
return visiblePropertyIds
|
||||
.filter(propertyId => propertyId !== titleColumn)
|
||||
.map(propertyId => this.propertyGetOrCreate(propertyId));
|
||||
});
|
||||
|
||||
rowEntries$ = computed<CalendarRowEntry[]>(() => {
|
||||
const mapping = this.dateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return [];
|
||||
}
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
return this.rows$.value.flatMap(row => {
|
||||
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
if (!isValidTimestamp(startAt)) {
|
||||
return [];
|
||||
}
|
||||
const endAt =
|
||||
endMapping.status === 'ready'
|
||||
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
|
||||
.value
|
||||
: undefined;
|
||||
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
|
||||
const jsonTitle = titleCell.jsonValue$.value;
|
||||
const title =
|
||||
(typeof jsonTitle === 'string'
|
||||
? jsonTitle
|
||||
: titleCell.stringValue$.value) ?? '';
|
||||
const docDisplayMeta = this.manager.dataSource.serviceGet(
|
||||
DocDisplayMetaProvider
|
||||
);
|
||||
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
|
||||
docDisplayMeta?.title(pageId, { title }).value;
|
||||
const titleSegments = getTitleSegments(
|
||||
titleCell.value$.value,
|
||||
title,
|
||||
resolveLinkedDocTitle
|
||||
);
|
||||
const cardProperties = this.visibleCardProperties$.value.flatMap(
|
||||
property => {
|
||||
const cell = this.cellGetOrCreate(row.rowId, property.id);
|
||||
const value = cell.stringValue$.value;
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
propertyId: property.id,
|
||||
value,
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
kind: 'row',
|
||||
id: `database:${row.rowId}`,
|
||||
sourceId: 'database',
|
||||
rowId: row.rowId,
|
||||
title,
|
||||
startAt,
|
||||
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
|
||||
titleSegments,
|
||||
cardProperties,
|
||||
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
|
||||
} satisfies CalendarRowEntry;
|
||||
});
|
||||
});
|
||||
|
||||
entries$ = computed<CalendarEntry[]>(() => {
|
||||
return [...this.rowEntries$.value, ...this.externalEntries$.value];
|
||||
});
|
||||
|
||||
externalSources$ = computed<CalendarExternalSource[]>(() => {
|
||||
const viewData = this.data$.value;
|
||||
if (!viewData) {
|
||||
return [];
|
||||
}
|
||||
return getCalendarExternalSources(this.dataSource, viewData);
|
||||
});
|
||||
|
||||
get type(): string {
|
||||
return this.data$.value?.mode ?? 'calendar';
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
}
|
||||
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
);
|
||||
return evalFilter(this.filter$.value, rowMap);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override rowsMapping(rows: Row[]) {
|
||||
return this.sortManager.sort(super.rowsMapping(rows));
|
||||
}
|
||||
|
||||
propertyGetOrCreate(propertyId: string): CalendarProperty {
|
||||
return new CalendarProperty(this, propertyId);
|
||||
}
|
||||
|
||||
override rowGetOrCreate(rowId: string): CalendarRow {
|
||||
return new CalendarRow(this, rowId);
|
||||
}
|
||||
|
||||
setStartDateColumn(propertyId: string) {
|
||||
this.dataUpdate(data => ({
|
||||
date: {
|
||||
...getDateData(data),
|
||||
startColumnId: propertyId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setDateColumn(propertyId: string) {
|
||||
this.setStartDateColumn(propertyId);
|
||||
}
|
||||
|
||||
setEndDateColumn(propertyId: string | undefined) {
|
||||
this.dataUpdate(data => ({
|
||||
date: {
|
||||
...getDateData(data),
|
||||
endColumnId: propertyId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setWorkspaceCalendarEnabled(enabled: boolean) {
|
||||
this.dataUpdate(data => ({
|
||||
sources: {
|
||||
...data.sources,
|
||||
workspaceCalendar: {
|
||||
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
|
||||
this.dataUpdate(data => ({
|
||||
sources: {
|
||||
...data.sources,
|
||||
workspaceCalendar: {
|
||||
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||
subscriptionIds,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
dismissEmptyMonthHint() {
|
||||
this.dataUpdate(data => ({
|
||||
ui: {
|
||||
...data.ui,
|
||||
emptyMonthHintDismissed: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
getDocDisplayTitle(docId: string) {
|
||||
return (
|
||||
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
|
||||
.value ?? 'Untitled'
|
||||
);
|
||||
}
|
||||
|
||||
createStartDateColumn() {
|
||||
const id = this.propertyAdd('end', {
|
||||
type: 'date',
|
||||
name: 'Date',
|
||||
});
|
||||
if (id) {
|
||||
this.setStartDateColumn(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createDateColumn() {
|
||||
return this.createStartDateColumn();
|
||||
}
|
||||
|
||||
createEndDateColumn() {
|
||||
const id = this.propertyAdd('end', {
|
||||
type: 'date',
|
||||
name: 'End Date',
|
||||
});
|
||||
if (id) {
|
||||
this.setEndDateColumn(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createRowOnDate(date: number | Date) {
|
||||
const mapping = this.startDateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const rowId = this.rowAdd('end');
|
||||
const filter = this.filter$.value;
|
||||
if (filter.conditions.length > 0) {
|
||||
const defaultValues = generateDefaultValues(filter, this.vars$.value);
|
||||
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
|
||||
const property = this.propertyGetOrCreate(propertyId);
|
||||
const propertyMeta = property.meta$.value;
|
||||
if (propertyMeta) {
|
||||
const value = fromJson(propertyMeta.config, {
|
||||
value: jsonValue,
|
||||
data: property.data$.value,
|
||||
dataSource: this.dataSource,
|
||||
});
|
||||
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
|
||||
toTimestamp(date)
|
||||
);
|
||||
this.dismissEmptyMonthHint();
|
||||
return rowId;
|
||||
}
|
||||
|
||||
createLinkedDocRowOnDate(date: number | Date, docId: string) {
|
||||
const rowId = this.createRowOnDate(date);
|
||||
if (!rowId) return;
|
||||
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||
this.cellGetOrCreate(rowId, titleColumn).valueSet(
|
||||
createLinkedDocTitle(docId)
|
||||
);
|
||||
return rowId;
|
||||
}
|
||||
|
||||
moveRowToDate(rowId: string, date: number | Date) {
|
||||
const mapping = this.startDateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const value = toTimestamp(date);
|
||||
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
|
||||
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
|
||||
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
|
||||
value + (oldEndAt - oldStartAt)
|
||||
);
|
||||
}
|
||||
}
|
||||
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
|
||||
}
|
||||
|
||||
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
|
||||
const startMapping = this.startDateMapping$.value;
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
|
||||
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
|
||||
const startAt = startCell.jsonValue$.value;
|
||||
const endAt = endCell.jsonValue$.value;
|
||||
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
|
||||
return;
|
||||
}
|
||||
const value = toTimestamp(date);
|
||||
if (edge === 'start') {
|
||||
startCell.jsonValueSet(Math.min(value, endAt));
|
||||
} else {
|
||||
endCell.jsonValueSet(Math.max(value, startAt));
|
||||
}
|
||||
}
|
||||
|
||||
async loadExternalEntries(range: CalendarEntryRange) {
|
||||
const requestId = ++this.externalEntriesRequestId;
|
||||
const viewData = this.data$.value;
|
||||
if (!viewData) {
|
||||
this.externalEntries$.value = [];
|
||||
return [];
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
this.externalSources$.value.map(source =>
|
||||
Promise.resolve(source.getEntries(range))
|
||||
)
|
||||
);
|
||||
const entries = results.flatMap(result =>
|
||||
result.status === 'fulfilled' ? result.value : []
|
||||
);
|
||||
if (requestId === this.externalEntriesRequestId) {
|
||||
this.externalEntries$.value = entries;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
export class CalendarProperty extends PropertyBase {
|
||||
hide$ = computed(() => false);
|
||||
|
||||
constructor(view: CalendarSingleView, propertyId: string) {
|
||||
super(view as SingleView, propertyId);
|
||||
}
|
||||
|
||||
hideSet(_hide: boolean): void {}
|
||||
|
||||
move(_position: InsertToPosition): void {}
|
||||
}
|
||||
|
||||
export class CalendarRow extends RowBase {
|
||||
constructor(
|
||||
readonly calendarView: CalendarSingleView,
|
||||
rowId: string
|
||||
) {
|
||||
super(calendarView, rowId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { viewType } from '../../core/view/data-view.js';
|
||||
import { CalendarSingleView } from './calendar-view-manager.js';
|
||||
import type { CalendarViewData } from './types.js';
|
||||
|
||||
export const calendarViewType = viewType('calendar');
|
||||
|
||||
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
|
||||
{
|
||||
defaultName: 'Calendar View',
|
||||
dataViewManager: CalendarSingleView,
|
||||
defaultData: viewManager => {
|
||||
return {
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
date: {},
|
||||
card: {
|
||||
titleColumnId: viewManager.dataSource.properties$.value.find(
|
||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||
),
|
||||
visiblePropertyIds: [],
|
||||
},
|
||||
sources: {
|
||||
workspaceCalendar: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
ui: {},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { pcEffects } from './pc/effect.js';
|
||||
|
||||
export function calendarEffects() {
|
||||
pcEffects();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './calendar-view-manager.js';
|
||||
export * from './define.js';
|
||||
export * from './layout.js';
|
||||
export * from './renderer.js';
|
||||
export * from './source.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { CalendarEntry } from './types.js';
|
||||
|
||||
export type CalendarDayLayout = {
|
||||
date: number;
|
||||
inMonth: boolean;
|
||||
entries: CalendarEntry[];
|
||||
segments: CalendarRangeSegment[];
|
||||
};
|
||||
|
||||
export type CalendarRangeSegment = {
|
||||
entry: CalendarEntry;
|
||||
weekIndex: number;
|
||||
startIndex: number;
|
||||
span: number;
|
||||
slot: number;
|
||||
startsBeforeWeek: boolean;
|
||||
endsAfterWeek: boolean;
|
||||
};
|
||||
|
||||
export type CalendarMonthLayout = {
|
||||
from: number;
|
||||
to: number;
|
||||
weeks: CalendarDayLayout[][];
|
||||
days: CalendarDayLayout[];
|
||||
segments: CalendarRangeSegment[];
|
||||
};
|
||||
|
||||
export type CalendarMonthLayoutOptions = {
|
||||
month: number | Date;
|
||||
entries: CalendarEntry[];
|
||||
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
};
|
||||
|
||||
const startOfDay = (date: Date) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
|
||||
const addDays = (date: number, days: number) => {
|
||||
const current = new Date(date);
|
||||
return startOfDay(
|
||||
new Date(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate() + days
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const endOfDay = (date: number) => addDays(date, 1) - 1;
|
||||
|
||||
const toDate = (value: number | Date) =>
|
||||
value instanceof Date ? value : new Date(value);
|
||||
|
||||
export const getCalendarVisibleMonthRange = (
|
||||
month: number | Date,
|
||||
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
|
||||
) => {
|
||||
const cursor = toDate(month);
|
||||
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
|
||||
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
|
||||
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
|
||||
const from = startOfDay(
|
||||
new Date(
|
||||
monthStart.getFullYear(),
|
||||
monthStart.getMonth(),
|
||||
monthStart.getDate() - startOffset
|
||||
)
|
||||
);
|
||||
const to = endOfDay(
|
||||
startOfDay(
|
||||
new Date(
|
||||
monthEnd.getFullYear(),
|
||||
monthEnd.getMonth(),
|
||||
monthEnd.getDate() + endOffset
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
monthStart: startOfDay(monthStart),
|
||||
monthEnd: endOfDay(startOfDay(monthEnd)),
|
||||
};
|
||||
};
|
||||
|
||||
const isRangeEntry = (entry: CalendarEntry) =>
|
||||
entry.endAt != null &&
|
||||
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
|
||||
|
||||
const getRangeEndDay = (entry: CalendarEntry) => {
|
||||
const endAt = entry.endAt ?? entry.startAt;
|
||||
const end = new Date(endAt);
|
||||
if (
|
||||
entry.kind === 'external' &&
|
||||
entry.allDay &&
|
||||
endAt > entry.startAt &&
|
||||
end.getHours() === 0 &&
|
||||
end.getMinutes() === 0 &&
|
||||
end.getSeconds() === 0 &&
|
||||
end.getMilliseconds() === 0
|
||||
) {
|
||||
return addDays(startOfDay(end), -1);
|
||||
}
|
||||
return startOfDay(end);
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
|
||||
days.findIndex(day => day.date === date);
|
||||
|
||||
const assignSegmentSlots = (
|
||||
weeks: CalendarDayLayout[][],
|
||||
segments: CalendarRangeSegment[]
|
||||
) => {
|
||||
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
|
||||
const weekSegments = segments.filter(
|
||||
segment => segment.weekIndex === weekIndex
|
||||
);
|
||||
const slots: boolean[][] = [];
|
||||
for (const segment of weekSegments) {
|
||||
let slot = 0;
|
||||
while (
|
||||
slots[slot]?.some(
|
||||
(occupied, index) =>
|
||||
occupied &&
|
||||
index >= segment.startIndex &&
|
||||
index < segment.startIndex + segment.span
|
||||
)
|
||||
) {
|
||||
slot++;
|
||||
}
|
||||
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
|
||||
for (
|
||||
let index = segment.startIndex;
|
||||
index < segment.startIndex + segment.span;
|
||||
index++
|
||||
) {
|
||||
slotDays[index] = true;
|
||||
}
|
||||
segment.slot = slot;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getCalendarDaySegmentSlots = (
|
||||
day: CalendarDayLayout,
|
||||
ignoredEntryId?: string
|
||||
) => {
|
||||
return Math.max(
|
||||
0,
|
||||
...day.segments
|
||||
.filter(segment => segment.entry.id !== ignoredEntryId)
|
||||
.map(segment => segment.slot + 1)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCalendarDayContentSlots = (
|
||||
day: CalendarDayLayout,
|
||||
ignoredEntryId?: string
|
||||
) => {
|
||||
return (
|
||||
getCalendarDaySegmentSlots(day, ignoredEntryId) +
|
||||
day.entries.filter(entry => entry.id !== ignoredEntryId).length
|
||||
);
|
||||
};
|
||||
|
||||
export const createCalendarMonthLayout = ({
|
||||
month,
|
||||
entries,
|
||||
weekStartsOn = 0,
|
||||
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
|
||||
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
|
||||
const cursor = toDate(month);
|
||||
const days: CalendarDayLayout[] = [];
|
||||
const dayByTime = new Map<number, CalendarDayLayout>();
|
||||
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
|
||||
const day: CalendarDayLayout = {
|
||||
date,
|
||||
inMonth:
|
||||
new Date(date).getMonth() === cursor.getMonth() &&
|
||||
new Date(date).getFullYear() === cursor.getFullYear(),
|
||||
entries: [],
|
||||
segments: [],
|
||||
};
|
||||
days.push(day);
|
||||
dayByTime.set(date, day);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isRangeEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
|
||||
if (day) {
|
||||
day.entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: CalendarRangeSegment[] = [];
|
||||
const rangeEntries = entries.filter(isRangeEntry);
|
||||
const visibleEndDay = startOfDay(new Date(range.to));
|
||||
for (const entry of rangeEntries) {
|
||||
const entryStart = startOfDay(new Date(entry.startAt));
|
||||
const entryEnd = getRangeEndDay(entry);
|
||||
if (entryEnd < range.from || entryStart > visibleEndDay) {
|
||||
continue;
|
||||
}
|
||||
const start = clamp(entryStart, range.from, visibleEndDay);
|
||||
const end = clamp(entryEnd, range.from, visibleEndDay);
|
||||
const startOffset = getDayOffset(days, start);
|
||||
const endOffset = getDayOffset(days, end);
|
||||
if (startOffset < 0 || endOffset < 0) {
|
||||
continue;
|
||||
}
|
||||
let offset = startOffset;
|
||||
while (offset <= endOffset) {
|
||||
const weekIndex = Math.floor(offset / 7);
|
||||
const startIndex = offset % 7;
|
||||
const weekEndOffset = weekIndex * 7 + 6;
|
||||
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
|
||||
const segment = {
|
||||
entry,
|
||||
weekIndex,
|
||||
startIndex,
|
||||
span,
|
||||
slot: 0,
|
||||
startsBeforeWeek: startOffset < weekIndex * 7,
|
||||
endsAfterWeek: endOffset > weekEndOffset,
|
||||
};
|
||||
segments.push(segment);
|
||||
for (let index = 0; index < span; index++) {
|
||||
days[offset + index]?.segments.push(segment);
|
||||
}
|
||||
offset += span;
|
||||
}
|
||||
}
|
||||
|
||||
const weeks: CalendarDayLayout[][] = [];
|
||||
for (let index = 0; index < days.length; index += 7) {
|
||||
weeks.push(days.slice(index, index + 7));
|
||||
}
|
||||
|
||||
assignSegmentSlots(weeks, segments);
|
||||
|
||||
return { from: range.from, to: range.to, weeks, days, segments };
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
CalendarPanelIcon,
|
||||
DateTimeIcon,
|
||||
PinIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { DataViewRootUILogic } from '../../../core/data-view.js';
|
||||
import type { CalendarSingleView } from '../calendar-view-manager.js';
|
||||
import type { CalendarEntry } from '../types.js';
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
});
|
||||
|
||||
export const formatEntryTime = (entry: CalendarEntry) => {
|
||||
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
|
||||
const start = formatter.format(new Date(entry.startAt));
|
||||
if (!entry.endAt) {
|
||||
return start;
|
||||
}
|
||||
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
|
||||
};
|
||||
|
||||
export const openCalendarEntry = (
|
||||
root: DataViewRootUILogic,
|
||||
view: CalendarSingleView,
|
||||
entry: CalendarEntry,
|
||||
target: HTMLElement,
|
||||
options?: { selectEntry?: (entryId: string | undefined) => void }
|
||||
) => {
|
||||
if (entry.kind === 'row') {
|
||||
options?.selectEntry?.(entry.id);
|
||||
root.openDetailPanel({
|
||||
view,
|
||||
rowId: entry.rowId,
|
||||
onClose: () => options?.selectEntry?.(undefined),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
popMenu(popupTargetFromElement(target), {
|
||||
options: {
|
||||
items: [
|
||||
() => html`
|
||||
<div class="calendar-event-popover">
|
||||
<div class="calendar-event-popover-title">${entry.title}</div>
|
||||
<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon"
|
||||
>${CalendarPanelIcon()}</span
|
||||
>
|
||||
<span>${entry.calendarName ?? 'Calendar event'}</span>
|
||||
</div>
|
||||
<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
|
||||
<span>${formatEntryTime(entry)}</span>
|
||||
</div>
|
||||
${entry.location
|
||||
? html`<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${PinIcon()}</span>
|
||||
<span>${entry.location}</span>
|
||||
</div>`
|
||||
: ''}
|
||||
${entry.description
|
||||
? html`<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${TextIcon()}</span>
|
||||
<span class="calendar-event-popover-description"
|
||||
>${entry.description}</span
|
||||
>
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { DndController } from '@blocksuite/std';
|
||||
|
||||
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
|
||||
import { getCalendarDateFromPoint } from './hit-test.js';
|
||||
|
||||
export type CalendarDndEntity =
|
||||
| {
|
||||
type: 'calendar-entry';
|
||||
entryId: string;
|
||||
}
|
||||
| {
|
||||
type: 'doc';
|
||||
docId: string;
|
||||
};
|
||||
|
||||
type CalendarDndData = {
|
||||
bsEntity?: unknown;
|
||||
entity?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
export const getCalendarDndEntity = (
|
||||
data: unknown
|
||||
): CalendarDndEntity | undefined => {
|
||||
if (!isRecord(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bsEntity = (data as CalendarDndData).bsEntity;
|
||||
if (isRecord(bsEntity)) {
|
||||
if (
|
||||
bsEntity.type === 'calendar-entry' &&
|
||||
typeof bsEntity.entryId === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'calendar-entry',
|
||||
entryId: bsEntity.entryId,
|
||||
};
|
||||
}
|
||||
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
|
||||
return {
|
||||
type: 'doc',
|
||||
docId: bsEntity.docId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const entity = (data as CalendarDndData).entity;
|
||||
if (
|
||||
isRecord(entity) &&
|
||||
entity.type === 'doc' &&
|
||||
typeof entity.id === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
docId: entity.id,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
export type CalendarDndCallbacks = {
|
||||
getEntry: (entryId: string) => CalendarEntry | undefined;
|
||||
canDragEntry: () => boolean;
|
||||
canDrop: (entity: CalendarDndEntity) => boolean;
|
||||
onEntryDragStart: (entry: CalendarRowEntry) => void;
|
||||
onEntryDragEnd: () => void;
|
||||
onDropTargetChange: (
|
||||
date: number | undefined,
|
||||
entity?: CalendarDndEntity
|
||||
) => void;
|
||||
onDrop: (entity: CalendarDndEntity, date: number) => void;
|
||||
};
|
||||
|
||||
type ElementCleanup = {
|
||||
element: HTMLElement;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
export class CalendarDnd {
|
||||
private readonly entryCleanups = new Map<string, ElementCleanup>();
|
||||
|
||||
private rootCleanup?: ElementCleanup;
|
||||
|
||||
constructor(
|
||||
private readonly dnd: DndController | undefined,
|
||||
private readonly callbacks: CalendarDndCallbacks
|
||||
) {}
|
||||
|
||||
bindRoot(element?: Element) {
|
||||
if (!this.dnd || !(element instanceof HTMLElement)) {
|
||||
this.cleanupRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rootCleanup?.element === element) {
|
||||
return;
|
||||
}
|
||||
this.cleanupRoot();
|
||||
|
||||
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
|
||||
element,
|
||||
getIsSticky: () => true,
|
||||
setDropData: ({ input }) => ({
|
||||
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
|
||||
}),
|
||||
canDrop: ({ source, input }) => {
|
||||
const entity = getCalendarDndEntity(source.data);
|
||||
const date = getCalendarDateFromPoint(
|
||||
element,
|
||||
input.clientX,
|
||||
input.clientY
|
||||
);
|
||||
return entity && date !== undefined
|
||||
? this.callbacks.canDrop(entity)
|
||||
: false;
|
||||
},
|
||||
onDrag: ({ source, location }) => {
|
||||
this.updateDropTarget(element, source.data, location.current.input);
|
||||
},
|
||||
onDragEnter: ({ source, location }) => {
|
||||
this.updateDropTarget(element, source.data, location.current.input);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
},
|
||||
onDrop: ({ source, location }) => {
|
||||
const entity = getCalendarDndEntity(source.data);
|
||||
const date = getCalendarDateFromPoint(
|
||||
element,
|
||||
location.current.input.clientX,
|
||||
location.current.input.clientY
|
||||
);
|
||||
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||
this.callbacks.onDrop(entity, date);
|
||||
}
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
this.rootCleanup = { element, cleanup };
|
||||
}
|
||||
|
||||
bindEntry(
|
||||
key: string,
|
||||
entry: CalendarEntry,
|
||||
element?: Element,
|
||||
disabled = false
|
||||
) {
|
||||
if (
|
||||
!this.dnd ||
|
||||
!(element instanceof HTMLElement) ||
|
||||
entry.kind !== 'row' ||
|
||||
disabled
|
||||
) {
|
||||
this.cleanupEntry(key);
|
||||
if (element instanceof HTMLElement) {
|
||||
element.setAttribute('draggable', 'false');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.entryCleanups.get(key);
|
||||
if (current?.element === element) {
|
||||
return;
|
||||
}
|
||||
this.cleanupEntry(key);
|
||||
|
||||
const cleanup = this.dnd.draggable<CalendarDndEntity>({
|
||||
element,
|
||||
canDrag: () => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
return currentEntry?.kind === 'row'
|
||||
? this.callbacks.canDragEntry()
|
||||
: false;
|
||||
},
|
||||
setDragData: () => ({
|
||||
type: 'calendar-entry',
|
||||
entryId: entry.id,
|
||||
}),
|
||||
setDragPreview: ({ container, setOffset }) => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
const preview = document.createElement('div');
|
||||
preview.textContent = currentEntry?.title || 'Untitled';
|
||||
preview.style.cssText =
|
||||
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
|
||||
'font-size:12px;white-space:nowrap;overflow:hidden;' +
|
||||
'background:var(--affine-hover-color,#f5f5f5);' +
|
||||
'color:var(--affine-text-primary-color,#333);' +
|
||||
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
|
||||
container.append(preview);
|
||||
setOffset({ x: 10, y: 11 });
|
||||
},
|
||||
onDragStart: () => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
if (currentEntry?.kind === 'row') {
|
||||
this.callbacks.onEntryDragStart(currentEntry);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
this.callbacks.onEntryDragEnd();
|
||||
},
|
||||
});
|
||||
|
||||
this.entryCleanups.set(key, { element, cleanup });
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupRoot();
|
||||
for (const key of this.entryCleanups.keys()) {
|
||||
this.cleanupEntry(key);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupEntry(key: string) {
|
||||
this.entryCleanups.get(key)?.cleanup();
|
||||
this.entryCleanups.delete(key);
|
||||
}
|
||||
|
||||
private cleanupRoot() {
|
||||
this.rootCleanup?.cleanup();
|
||||
this.rootCleanup = undefined;
|
||||
}
|
||||
|
||||
private updateDropTarget(
|
||||
root: HTMLElement,
|
||||
data: unknown,
|
||||
input: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
) {
|
||||
const entity = getCalendarDndEntity(data);
|
||||
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
|
||||
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||
this.callbacks.onDropTargetChange(date, entity);
|
||||
} else {
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CalendarViewUI } from './view.js';
|
||||
|
||||
export function pcEffects() {
|
||||
if (customElements.get('affine-data-view-calendar')) {
|
||||
return;
|
||||
}
|
||||
customElements.define('affine-data-view-calendar', CalendarViewUI);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const getCalendarDateFromPoint = (
|
||||
root: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => {
|
||||
const doc = root.ownerDocument;
|
||||
const hitStack = doc.elementsFromPoint(clientX, clientY);
|
||||
|
||||
for (const element of hitStack) {
|
||||
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
|
||||
if (day && root.contains(day)) {
|
||||
return Number(day.dataset['date']);
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of hitStack) {
|
||||
const week =
|
||||
element.closest<HTMLElement>('.calendar-week') ??
|
||||
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
|
||||
if (week && root.contains(week)) {
|
||||
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
|
||||
for (const day of days) {
|
||||
const rect = day.getBoundingClientRect();
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX < rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY < rect.bottom &&
|
||||
day.dataset['date']
|
||||
) {
|
||||
return Number(day.dataset['date']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1,708 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const calendarViewStyles = css`
|
||||
affine-data-view-calendar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
--calendar-entry-height: 22px;
|
||||
--calendar-entry-gap: 3px;
|
||||
--calendar-entry-slot-height: calc(
|
||||
var(--calendar-entry-height) + var(--calendar-entry-gap)
|
||||
);
|
||||
--calendar-grid-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-border-color) 58%,
|
||||
transparent
|
||||
);
|
||||
--calendar-entry-bg: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 12%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
--calendar-entry-hover-bg: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 18%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
--calendar-entry-text-color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 72%,
|
||||
var(--affine-text-primary-color)
|
||||
);
|
||||
--calendar-external-fallback-color: #b45309;
|
||||
}
|
||||
|
||||
.calendar-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.calendar-shell {
|
||||
position: relative;
|
||||
min-width: 720px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-nav button,
|
||||
.calendar-setup button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--affine-background-primary-color);
|
||||
color: var(--affine-text-primary-color);
|
||||
height: 28px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-nav button svg,
|
||||
.calendar-setup button svg,
|
||||
.calendar-new-row svg,
|
||||
.calendar-empty-month-hint-action svg,
|
||||
.calendar-empty-month-hint-close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-nav .calendar-icon-button {
|
||||
width: 28px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.calendar-nav .calendar-today-button {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-weekdays,
|
||||
.calendar-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-week {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-segments {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: var(--calendar-entry-slot-height);
|
||||
row-gap: 0;
|
||||
column-gap: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-segments .calendar-entry {
|
||||
align-self: start;
|
||||
height: var(--calendar-entry-height);
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.calendar-segments .calendar-entry-preview {
|
||||
align-self: start;
|
||||
pointer-events: none;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
border-top: 1px solid var(--calendar-grid-border-color);
|
||||
border-left: 1px solid var(--calendar-grid-border-color);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
position: relative;
|
||||
min-height: 112px;
|
||||
border-right: 1px solid var(--calendar-grid-border-color);
|
||||
border-bottom: 1px solid var(--calendar-grid-border-color);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.calendar-day.is-outside {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-background-secondary-color) 55%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day:not(.is-outside):hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 2%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-drop-target {
|
||||
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.calendar-day.is-today {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 6%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
margin-bottom: 4px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.calendar-day:not(.is-outside) .calendar-day-number {
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.calendar-day.is-outside .calendar-day-number {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-text-secondary-color) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-today .calendar-day-number {
|
||||
color: var(--affine-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 9%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--calendar-entry-height);
|
||||
margin-top: var(--calendar-entry-gap);
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--calendar-entry-text-color);
|
||||
background: var(--calendar-entry-bg);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-nav button:hover,
|
||||
.calendar-setup button:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.calendar-entry.row:hover {
|
||||
background: var(--calendar-entry-hover-bg);
|
||||
}
|
||||
|
||||
.calendar-entry:focus-visible {
|
||||
outline: 1px solid var(--affine-primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.calendar-entry.external:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.calendar-entry.selected {
|
||||
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 15%,
|
||||
var(--calendar-entry-bg)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry.continues-left {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-entry.continues-right {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry-title.is-empty {
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-entry-title.title-segments {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment {
|
||||
gap: 3px;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-entry-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.calendar-entry-properties {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-property {
|
||||
max-width: 72px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry.external {
|
||||
color: var(--affine-pure-white);
|
||||
background: var(
|
||||
--calendar-external-color,
|
||||
var(--calendar-external-fallback-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.calendar-entry[draggable='true']:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.calendar-resize-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.calendar-resize-handle.left {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.calendar-resize-handle.right {
|
||||
right: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.calendar-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 1px;
|
||||
background: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-resize-handle:hover::after {
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-entry:hover .calendar-resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-entry-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--calendar-entry-height);
|
||||
height: var(--calendar-entry-height);
|
||||
margin-top: var(--calendar-entry-gap);
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
border: 1.5px dashed var(--affine-primary-color);
|
||||
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-entry-preview svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-entry-preview.continues-left {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: none;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.calendar-entry-preview.continues-right {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: none;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.calendar-day-entries > .calendar-entry:first-child,
|
||||
.calendar-day-entries > .calendar-entry-preview:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.calendar-day-entries {
|
||||
padding-top: calc(
|
||||
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-new-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin-top: 3px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
padding: 3px 8px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
opacity 0.1s ease,
|
||||
background 0.1s ease;
|
||||
}
|
||||
|
||||
.calendar-new-row svg,
|
||||
.calendar-empty-month-hint-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row,
|
||||
.calendar-new-row:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 10%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row:disabled,
|
||||
.calendar-day.is-today:hover .calendar-new-row:disabled,
|
||||
.calendar-new-row:disabled {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover .calendar-new-row,
|
||||
.calendar-day.is-today .calendar-new-row:focus-visible {
|
||||
background: var(--affine-primary-color);
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.calendar-day.is-today .calendar-new-row:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 88%,
|
||||
var(--affine-pure-white)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover .calendar-new-row svg,
|
||||
.calendar-day.is-today .calendar-new-row:focus-visible svg {
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.calendar-new-row:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 16%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-background-primary-color) 92%,
|
||||
var(--affine-primary-color)
|
||||
);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-copy {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-body {
|
||||
min-width: 0;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-action,
|
||||
.calendar-empty-month-hint-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 10%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-close {
|
||||
width: 24px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-action:hover,
|
||||
.calendar-empty-month-hint-close:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 16%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-setup-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-setup-wrap .calendar-shell {
|
||||
filter: grayscale(1) blur(1px);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-setup {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-setup button {
|
||||
height: 32px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.calendar-event-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 318px;
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-title {
|
||||
padding: 2px 4px;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 2px 4px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-event-popover-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 16px;
|
||||
height: 20px;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-event-popover-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-description {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import './pc/effect.js';
|
||||
|
||||
import { createIcon } from '../../core/utils/uni-icon.js';
|
||||
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
|
||||
import { calendarViewModel } from './define.js';
|
||||
import { CalendarViewUILogic } from './pc/view.js';
|
||||
|
||||
export const calendarViewMeta = calendarViewModel.createMeta({
|
||||
icon: createIcon('TodayIcon'),
|
||||
pcLogic: () =>
|
||||
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import type { DataSource } from '../../core/data-source/base.js';
|
||||
import type {
|
||||
CalendarExternalSource,
|
||||
CalendarStoredViewData,
|
||||
} from './types.js';
|
||||
|
||||
export type CalendarExternalSourceFactory = {
|
||||
id: string;
|
||||
create(viewData: CalendarStoredViewData): CalendarExternalSource;
|
||||
};
|
||||
|
||||
export const CalendarExternalSourceProvider =
|
||||
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
|
||||
|
||||
export const getCalendarExternalSources = (
|
||||
dataSource: DataSource,
|
||||
viewData: CalendarStoredViewData
|
||||
) =>
|
||||
Array.from(
|
||||
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
|
||||
).map(source => source.create(viewData));
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import type { Sort } from '../../core/sort/types.js';
|
||||
import type { BasicViewDataType } from '../../core/view/data-view.js';
|
||||
|
||||
export type CalendarWorkspaceSourceConfig = {
|
||||
enabled: boolean;
|
||||
subscriptionIds?: string[];
|
||||
};
|
||||
|
||||
export type CalendarUiData = {
|
||||
emptyMonthHintDismissed?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarCardProperty = {
|
||||
propertyId: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CalendarTitleSegment = {
|
||||
text: string;
|
||||
linkedDoc?: boolean;
|
||||
};
|
||||
|
||||
type CalendarViewDataShape = {
|
||||
filter: FilterGroup;
|
||||
sort?: Sort;
|
||||
date: {
|
||||
startColumnId?: string;
|
||||
endColumnId?: string;
|
||||
};
|
||||
card: {
|
||||
titleColumnId?: string;
|
||||
visiblePropertyIds: string[];
|
||||
};
|
||||
sources: {
|
||||
workspaceCalendar?: CalendarWorkspaceSourceConfig;
|
||||
};
|
||||
ui?: CalendarUiData;
|
||||
};
|
||||
|
||||
export type CalendarViewData = BasicViewDataType<
|
||||
'calendar',
|
||||
CalendarViewDataShape
|
||||
>;
|
||||
|
||||
export type CalendarStoredViewData = CalendarViewData;
|
||||
|
||||
export type CalendarEntryBase = {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
startAt: number;
|
||||
endAt?: number;
|
||||
allDay?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarRowEntry = CalendarEntryBase & {
|
||||
kind: 'row';
|
||||
sourceId: 'database';
|
||||
rowId: string;
|
||||
titleSegments?: CalendarTitleSegment[];
|
||||
cardProperties: CalendarCardProperty[];
|
||||
canResizeRange: boolean;
|
||||
};
|
||||
|
||||
export type CalendarExternalEntry = CalendarEntryBase & {
|
||||
kind: 'external';
|
||||
sourceId: string;
|
||||
externalId: string;
|
||||
calendarName?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
canResizeRange: false;
|
||||
};
|
||||
|
||||
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
|
||||
|
||||
export type CalendarEntryRange = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type CalendarExternalSource = {
|
||||
id: string;
|
||||
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
|
||||
openConnectSettings?(): void;
|
||||
getEntries(
|
||||
range: CalendarEntryRange
|
||||
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
|
||||
};
|
||||
|
||||
export type CalendarExternalSourceSubscription = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
@@ -1,13 +1,45 @@
|
||||
import { createViewConvert } from '../core/view/convert.js';
|
||||
import { calendarViewModel } from './calendar/index.js';
|
||||
import { kanbanViewModel } from './kanban/index.js';
|
||||
import { tableViewModel } from './table/index.js';
|
||||
|
||||
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
|
||||
titleColumnId: header?.titleColumn,
|
||||
visiblePropertyIds: [],
|
||||
});
|
||||
|
||||
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
|
||||
titleColumn: card?.titleColumnId,
|
||||
});
|
||||
|
||||
export const viewConverts = [
|
||||
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
header: data.header,
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
header: data.header,
|
||||
groupBy: data.groupBy,
|
||||
})),
|
||||
createViewConvert(tableViewModel, calendarViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
card: headerToCalendarCard(data.header),
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
card: headerToCalendarCard(data.header),
|
||||
})),
|
||||
createViewConvert(calendarViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
header: calendarCardToHeader(data.card),
|
||||
})),
|
||||
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
header: calendarCardToHeader(data.card),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { calendarEffects } from './calendar/effect.js';
|
||||
import { kanbanEffects } from './kanban/effect.js';
|
||||
import { tableEffects } from './table/effect.js';
|
||||
|
||||
export function viewPresetsEffects() {
|
||||
calendarEffects();
|
||||
kanbanEffects();
|
||||
tableEffects();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { calendarViewMeta } from './calendar/index.js';
|
||||
import { kanbanViewMeta } from './kanban/index.js';
|
||||
import { tableViewMeta } from './table/index.js';
|
||||
|
||||
export * from './calendar/index.js';
|
||||
export * from './convert.js';
|
||||
export * from './kanban/index.js';
|
||||
export * from './table/index.js';
|
||||
@@ -8,4 +10,5 @@ export * from './table/index.js';
|
||||
export const viewPresets = {
|
||||
tableViewMeta: tableViewMeta,
|
||||
kanbanViewMeta: kanbanViewMeta,
|
||||
calendarViewMeta: calendarViewMeta,
|
||||
};
|
||||
|
||||
+492
-369
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type Menu,
|
||||
menu,
|
||||
type MenuButtonData,
|
||||
type MenuConfig,
|
||||
@@ -16,22 +17,22 @@ import {
|
||||
InfoIcon,
|
||||
LayoutIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
SortIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { popPropertiesSetting } from '../../../../core/common/properties.js';
|
||||
import { filterTraitKey } from '../../../../core/filter/trait.js';
|
||||
import {
|
||||
popGroupSetting,
|
||||
popSelectGroupByProperty,
|
||||
buildGroupSelectItems,
|
||||
buildGroupSettingItems,
|
||||
} from '../../../../core/group-by/setting.js';
|
||||
import { groupTraitKey } from '../../../../core/group-by/trait.js';
|
||||
import {
|
||||
type DataViewUILogicBase,
|
||||
emptyFilterGroup,
|
||||
popCreateFilter,
|
||||
renderUniLit,
|
||||
} from '../../../../core/index.js';
|
||||
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
|
||||
import { sortTraitKey } from '../../../../core/sort/manager.js';
|
||||
import { createSortUtils } from '../../../../core/sort/utils.js';
|
||||
import { WidgetBase } from '../../../../core/widget/widget-base.js';
|
||||
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
|
||||
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
|
||||
|
||||
const styles = css`
|
||||
.affine-database-toolbar-item.more-action {
|
||||
@@ -95,379 +94,486 @@ declare global {
|
||||
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
|
||||
}
|
||||
}
|
||||
const createSettingMenus = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
reopen: () => void,
|
||||
closeMenu: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const settingItems: MenuConfig[] = [];
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
popPropertiesSetting(
|
||||
target,
|
||||
{
|
||||
view: view,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (filterTrait) {
|
||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
: filterCount === 1
|
||||
? '1 filter'
|
||||
: `${filterCount} filters`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
if (!filterTrait.filter$.value.conditions.length) {
|
||||
popCreateFilter(
|
||||
target,
|
||||
{
|
||||
vars: view.vars$,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...(filterTrait.filter$.value ?? emptyFilterGroup),
|
||||
conditions: [
|
||||
...filterTrait.filter$.value.conditions,
|
||||
filter,
|
||||
],
|
||||
});
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
},
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (sortTrait) {
|
||||
const sortCount = sortTrait.sortList$.value.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
: sortCount === 1
|
||||
? '1 sort'
|
||||
: `${sortCount} sorts`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const sortList = sortTrait.sortList$.value;
|
||||
const sortUtils = createSortUtils(
|
||||
sortTrait,
|
||||
dataViewLogic.eventTrace
|
||||
);
|
||||
if (!sortList.length) {
|
||||
popCreateSort(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popSortRoot(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
title: {
|
||||
text: 'Sort',
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (groupTrait) {
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const groupBy = groupTrait.property$.value;
|
||||
if (!groupBy) {
|
||||
popSelectGroupByProperty(
|
||||
target,
|
||||
groupTrait,
|
||||
{
|
||||
onSelect: () =>
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]),
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return settingItems;
|
||||
type Page =
|
||||
| 'main'
|
||||
| 'properties'
|
||||
| 'filter'
|
||||
| 'sort'
|
||||
| 'group'
|
||||
| 'group-select'
|
||||
| 'custom';
|
||||
|
||||
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
|
||||
main: 'View settings',
|
||||
properties: 'Properties',
|
||||
filter: 'Filter',
|
||||
sort: 'Sort',
|
||||
group: 'Group',
|
||||
'group-select': 'Group by',
|
||||
};
|
||||
|
||||
export const popViewOptions = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
onClose?: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const reopen = () => {
|
||||
popViewOptions(target, dataViewLogic);
|
||||
};
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
const items: MenuConfig[] = [];
|
||||
items.push(
|
||||
menu.input({
|
||||
initialValue: view.name$.value,
|
||||
placeholder: 'View name',
|
||||
onChange: text => {
|
||||
view.nameSet(text);
|
||||
},
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu => {
|
||||
const viewTypeItems = menu.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menu => {
|
||||
if (!menu.search(meta.model.defaultName)) {
|
||||
return;
|
||||
}
|
||||
const isSelected =
|
||||
meta.type === view.manager.currentView$.value?.type;
|
||||
const iconStyle = styleMap({
|
||||
fontSize: '24px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
const textStyle = styleMap({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<div
|
||||
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
||||
>
|
||||
<div style="${iconStyle}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
</div>
|
||||
<div style="${textStyle}">${meta.model.defaultName}</div>
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
dataViewLogic.clearSelection();
|
||||
},
|
||||
class: {},
|
||||
};
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menu}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: createSettingMenus(target, dataViewLogic, reopen, () =>
|
||||
handler.close()
|
||||
),
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
const currentPage = signal<Page>('main');
|
||||
const pageStack: Page[] = ['main'];
|
||||
|
||||
let menuHandler!: ReturnType<typeof popMenu>;
|
||||
let mainPageHeight: number | null = null;
|
||||
let customPageTitle = '';
|
||||
let customPageItems: () => MenuConfig[] = () => [];
|
||||
|
||||
const isDesktopMenu = () =>
|
||||
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
|
||||
|
||||
const navigate = (page: Page) => {
|
||||
if (!isDesktopMenu()) {
|
||||
pageStack.push(page);
|
||||
currentPage.value = page;
|
||||
return;
|
||||
}
|
||||
if (mainPageHeight === null) {
|
||||
mainPageHeight =
|
||||
menuHandler.menu.menuElement.getBoundingClientRect().height;
|
||||
}
|
||||
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
|
||||
pageStack.push(page);
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (pageStack.length > 1) {
|
||||
pageStack.pop();
|
||||
const dest = pageStack[pageStack.length - 1] ?? 'main';
|
||||
currentPage.value = dest;
|
||||
if (dest === 'main') {
|
||||
menuHandler.menu.menuElement.style.height = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCustomPage = (
|
||||
title: string,
|
||||
getItems: () => MenuConfig[]
|
||||
) => {
|
||||
customPageTitle = title;
|
||||
customPageItems = getItems;
|
||||
navigate('custom');
|
||||
};
|
||||
|
||||
const titleConfig = {
|
||||
get text() {
|
||||
if (currentPage.value === 'custom') return customPageTitle;
|
||||
return (
|
||||
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
|
||||
'View settings'
|
||||
);
|
||||
},
|
||||
get onBack(): ((menu: Menu) => false) | undefined {
|
||||
return currentPage.value !== 'main'
|
||||
? (_: Menu) => {
|
||||
goBack();
|
||||
return false;
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
get postfix() {
|
||||
if (currentPage.value !== 'properties') return undefined;
|
||||
const items = view.propertiesRaw$.value;
|
||||
const isAllShowed = items.every(p => !p.hide$.value);
|
||||
const clickChangeAll = () => {
|
||||
items.forEach(p => {
|
||||
if (p.hideCanSet) p.hideSet(isAllShowed);
|
||||
});
|
||||
};
|
||||
return () =>
|
||||
html`<div
|
||||
class="properties-group-op"
|
||||
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
|
||||
@click="${clickChangeAll}"
|
||||
>
|
||||
${isAllShowed ? 'Hide All' : 'Show All'}
|
||||
</div>`;
|
||||
},
|
||||
get onClose() {
|
||||
return () => menuHandler?.menu.close();
|
||||
},
|
||||
};
|
||||
|
||||
const getPropertiesPageItems = (): MenuConfig[] => [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
class: { 'delete-item': true },
|
||||
}),
|
||||
() =>
|
||||
html`<data-view-properties-setting
|
||||
.view="${view}"
|
||||
></data-view-properties-setting>`,
|
||||
],
|
||||
})
|
||||
);
|
||||
handler = popMenu(target, {
|
||||
}),
|
||||
];
|
||||
|
||||
const getFilterPageItems = (): MenuConfig[] => {
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (!filterTrait) return getMainPageItems();
|
||||
return [
|
||||
menu.group({
|
||||
items: [
|
||||
() =>
|
||||
html`<filter-root-view
|
||||
.onBack="${goBack}"
|
||||
.vars="${view.vars$}"
|
||||
.filterGroup="${filterTrait.filter$}"
|
||||
.onChange="${filterTrait.filterSet}"
|
||||
></filter-root-view>`,
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Add',
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
const value = filterTrait.filter$.value;
|
||||
popCreateFilter(popupTargetFromElement(ele), {
|
||||
vars: view.vars$,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...value,
|
||||
conditions: [...value.conditions, filter],
|
||||
});
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
});
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getSortPageItems = (): MenuConfig[] => {
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (!sortTrait) return getMainPageItems();
|
||||
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
|
||||
return [
|
||||
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
|
||||
menu.action({
|
||||
name: 'Add sort',
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
popCreateSort(popupTargetFromElement(ele), { sortUtils });
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: { 'delete-item': true },
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
sortUtils.removeAll();
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getGroupPageItems = (): MenuConfig[] => {
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (!groupTrait) return getMainPageItems();
|
||||
const gProp = groupTrait.property$.value;
|
||||
if (!gProp) return [];
|
||||
return buildGroupSettingItems(
|
||||
groupTrait,
|
||||
() => navigate('group-select'),
|
||||
() => navigate('main')
|
||||
);
|
||||
};
|
||||
|
||||
const getGroupSelectPageItems = (): MenuConfig[] => {
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (!groupTrait) return getMainPageItems();
|
||||
return buildGroupSelectItems(groupTrait, id => {
|
||||
if (id) {
|
||||
if (pageStack.at(-1) === 'group-select') {
|
||||
pageStack[pageStack.length - 1] = 'group';
|
||||
} else {
|
||||
pageStack.push('group');
|
||||
}
|
||||
currentPage.value = 'group';
|
||||
} else {
|
||||
while (pageStack.length > 1) pageStack.pop();
|
||||
currentPage.value = 'main';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getMainPageItems = (): MenuConfig[] => {
|
||||
const items: MenuConfig[] = [];
|
||||
|
||||
items.push(
|
||||
menu.input({
|
||||
initialValue: view.name$.value,
|
||||
placeholder: 'View name',
|
||||
disableAutoFocus: true,
|
||||
onChange: text => {
|
||||
view.nameSet(text);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menuObj => {
|
||||
const viewTypeItems = menuObj.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menuObj => {
|
||||
if (!menuObj.search(meta.model.defaultName)) {
|
||||
return;
|
||||
}
|
||||
const isSelected =
|
||||
meta.type === view.manager.currentView$.value?.type;
|
||||
const iconStyle = styleMap({
|
||||
fontSize: '24px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
const textStyle = styleMap({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<div
|
||||
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
|
||||
>
|
||||
<div style="${iconStyle}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
</div>
|
||||
<div style="${textStyle}">
|
||||
${meta.model.defaultName}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
dataViewLogic.clearSelection();
|
||||
},
|
||||
class: {},
|
||||
};
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menuObj}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
|
||||
>
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const settingItems: MenuConfig[] = [];
|
||||
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('properties');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (filterTrait) {
|
||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
: filterCount === 1
|
||||
? '1 active'
|
||||
: `${filterCount} active`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('filter');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (sortTrait) {
|
||||
const sortCount = sortTrait.sortList$.value.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
: sortCount === 1
|
||||
? '1 active'
|
||||
: `${sortCount} active`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('sort');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (groupTrait) {
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
const hasGroup = !!groupTrait.property$.value;
|
||||
navigate(hasGroup ? 'group' : 'group-select');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
items.push(menu.group({ items: settingItems }));
|
||||
|
||||
const viewSpecificItems =
|
||||
(
|
||||
dataViewLogic as DataViewUILogicBase & {
|
||||
getViewOptionsSettingItems?: (
|
||||
navigateToSubPage?: (
|
||||
title: string,
|
||||
getItems: () => MenuConfig[]
|
||||
) => void,
|
||||
goBack?: () => void
|
||||
) => MenuConfig[];
|
||||
}
|
||||
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
|
||||
|
||||
if (viewSpecificItems.length) {
|
||||
items.push(menu.group({ items: viewSpecificItems }));
|
||||
}
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate view',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete view',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
class: { 'delete-item': true },
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getPageItems = (): MenuConfig[] => {
|
||||
switch (currentPage.value) {
|
||||
case 'properties':
|
||||
return getPropertiesPageItems();
|
||||
case 'filter':
|
||||
return getFilterPageItems();
|
||||
case 'sort':
|
||||
return getSortPageItems();
|
||||
case 'group':
|
||||
return getGroupPageItems();
|
||||
case 'group-select':
|
||||
return getGroupSelectPageItems();
|
||||
case 'custom':
|
||||
return customPageItems();
|
||||
default:
|
||||
return getMainPageItems();
|
||||
}
|
||||
};
|
||||
|
||||
menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'View settings',
|
||||
onClose: () => handler.close(),
|
||||
},
|
||||
items,
|
||||
onClose: onClose,
|
||||
title: titleConfig,
|
||||
items: [menu.dynamic(getPageItems)],
|
||||
onClose,
|
||||
},
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
||||
@@ -475,6 +581,23 @@ export const popViewOptions = (
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
return handler;
|
||||
if (isDesktopMenu()) {
|
||||
menuHandler.menu.menuElement.style.minWidth = '380px';
|
||||
menuHandler.menu.menuElement.style.maxWidth = '380px';
|
||||
menuHandler.menu.menuElement.style.borderRadius = '10px';
|
||||
menuHandler.menu.menuElement.style.padding = '12px';
|
||||
menuHandler.menu.menuElement.style.gap = '10px';
|
||||
requestAnimationFrame(() => {
|
||||
const bodyEl =
|
||||
menuHandler.menu.menuElement.querySelector<HTMLElement>(
|
||||
'.affine-menu-body'
|
||||
);
|
||||
if (bodyEl) {
|
||||
bodyEl.style.overflowY = 'auto';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.minHeight = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
return menuHandler;
|
||||
};
|
||||
|
||||
+2
-2
@@ -74,7 +74,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-lit": "^2.2.1",
|
||||
"eslint-plugin-oxlint": "1.60.0",
|
||||
"eslint-plugin-oxlint": "1.66.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
@@ -84,7 +84,7 @@
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.13.2",
|
||||
"oxlint": "1.58.0",
|
||||
"oxlint-tsgolint": "^0.19.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"prettier": "^3.7.4",
|
||||
"semver": "^7.7.3",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -9,13 +9,13 @@ version = "1.0.0"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = { workspace = true }
|
||||
affine_common = { workspace = true, features = [
|
||||
"doc-loader",
|
||||
"hashcash",
|
||||
"napi",
|
||||
"ydoc-loader",
|
||||
] }
|
||||
aes-gcm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
base64-simd = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
@@ -38,6 +38,7 @@ reqwest = { version = "0.13.3", default-features = false, features = [
|
||||
"blocking",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = "0.23"
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -46,6 +47,7 @@ sha3 = { workspace = true }
|
||||
tiktoken-rs = { workspace = true }
|
||||
url = { workspace = true }
|
||||
v_htmlescape = { workspace = true }
|
||||
webpki-roots = "1"
|
||||
y-octo = { workspace = true, features = ["large_refs"] }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
|
||||
@@ -706,8 +706,8 @@
|
||||
"optionalModels": [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"claude-sonnet-4-5@20250929"
|
||||
"gemini-3.5-flash",
|
||||
"claude-sonnet-4-6"
|
||||
],
|
||||
"config": {
|
||||
"tools": [
|
||||
@@ -722,11 +722,7 @@
|
||||
"codeArtifact",
|
||||
"blobRead"
|
||||
],
|
||||
"proModels": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"claude-sonnet-4-5@20250929"
|
||||
]
|
||||
"proModels": ["gemini-2.5-pro", "gemini-3.5-flash", "claude-sonnet-4-6"]
|
||||
},
|
||||
"builtins": [
|
||||
"date",
|
||||
|
||||
@@ -61,12 +61,12 @@ mod tests {
|
||||
fn should_resolve_backend_scoped_alias() {
|
||||
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
|
||||
backend_kind: Some("anthropic_vertex".to_string()),
|
||||
model_id: "claude-sonnet-4.5".to_string(),
|
||||
model_id: "claude-sonnet-4.6".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.matched_by.as_deref(), Some("canonical"));
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-5@20250929");
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,10 @@ fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut restrictions = Vec::new();
|
||||
if !input.runtime.known {
|
||||
restrictions.push(PermissionDecisionRestrictionV1 {
|
||||
|
||||
@@ -347,9 +347,12 @@ mod tests {
|
||||
local: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.runtime.known = false;
|
||||
input.runtime.stale = true;
|
||||
input.workspace_actions = vec!["Workspace.Delete".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.workspace.decisions, "Workspace.Delete").allowed);
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -412,11 +412,25 @@ fn build_pinned_client(url: &Url, addrs: &[SocketAddr], timeout: Duration) -> An
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.tls_backend_preconfigured(webpki_tls_config()?)
|
||||
.resolve_to_addrs(host, addrs)
|
||||
.build()
|
||||
.context("failed to build http client")
|
||||
}
|
||||
|
||||
fn webpki_tls_config() -> AnyResult<rustls::ClientConfig> {
|
||||
let root_store = rustls::RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
||||
};
|
||||
Ok(
|
||||
rustls::ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into())
|
||||
.with_safe_default_protocol_versions()
|
||||
.context("failed to build tls protocol config")?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_headers(headers: Option<&HashMap<String, String>>) -> AnyResult<HeaderMap> {
|
||||
let mut out = HeaderMap::new();
|
||||
let Some(headers) = headers else {
|
||||
@@ -623,6 +637,13 @@ mod tests {
|
||||
assert!(!is_blocked_ip("2002:0808:0808::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_https_client_with_embedded_roots() {
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
let addrs = ["93.184.216.34:443".parse().unwrap()];
|
||||
build_pinned_client(&url, &addrs, Duration::from_secs(1)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_png_dimensions_without_decode() {
|
||||
let png = base64_simd::STANDARD
|
||||
|
||||
@@ -5,6 +5,7 @@ import ava, { type ExecutionContext, type TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Cache, CryptoHelper } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { Models, WorkspaceRole } from '../../models';
|
||||
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
|
||||
import { ByokService } from '../../plugins/copilot/byok';
|
||||
@@ -14,6 +15,11 @@ import {
|
||||
ByokKeyTestStatus,
|
||||
ByokProvider,
|
||||
} from '../../plugins/copilot/byok/types';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../plugins/payment/types';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
@@ -24,11 +30,18 @@ interface Context {
|
||||
byok: ByokService;
|
||||
crypto: CryptoHelper;
|
||||
cache: Cache;
|
||||
entitlement: EntitlementService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
const test = ava.serial as TestFn<Context>;
|
||||
const originalNamespace = globalThis.env.NAMESPACE;
|
||||
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
|
||||
test.before(async t => {
|
||||
Object.assign(globalThis.env, {
|
||||
NAMESPACE: 'dev',
|
||||
DEPLOYMENT_TYPE: 'affine',
|
||||
});
|
||||
const module = await createTestingModule();
|
||||
t.context.module = module;
|
||||
t.context.models = module.get(Models);
|
||||
@@ -37,6 +50,7 @@ test.before(async t => {
|
||||
t.context.byok = module.get(ByokService);
|
||||
t.context.crypto = module.get(CryptoHelper);
|
||||
t.context.cache = module.get(Cache);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
@@ -45,6 +59,10 @@ test.beforeEach(async t => {
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
Object.assign(globalThis.env, {
|
||||
NAMESPACE: originalNamespace,
|
||||
DEPLOYMENT_TYPE: originalDeploymentType,
|
||||
});
|
||||
});
|
||||
|
||||
async function createUserWorkspace(t: ExecutionContext<Context>) {
|
||||
@@ -59,6 +77,73 @@ function workspaceHash(workspaceId: string) {
|
||||
return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
async function grantUserPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
userId: string,
|
||||
feature: ByokUserPlanFeature = 'pro_plan_v1'
|
||||
) {
|
||||
if (feature === 'unlimited_copilot') {
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring:
|
||||
feature === 'lifetime_pro_plan_v1'
|
||||
? SubscriptionRecurring.Lifetime
|
||||
: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeUserPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
userId: string,
|
||||
feature: ByokUserPlanFeature = 'pro_plan_v1'
|
||||
) {
|
||||
if (feature === 'unlimited_copilot') {
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
});
|
||||
}
|
||||
|
||||
async function grantTeamPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string
|
||||
) {
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeTeamPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string
|
||||
) {
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
});
|
||||
}
|
||||
|
||||
type ByokMatrixCase = {
|
||||
name: string;
|
||||
role: WorkspaceRole;
|
||||
@@ -110,25 +195,13 @@ async function createByokMatrixWorkspace(
|
||||
);
|
||||
}
|
||||
if (input.team) {
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'team_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantTeamPlan(t, workspace.id);
|
||||
}
|
||||
if (input.ownerPlan) {
|
||||
await t.context.models.userFeature.add(
|
||||
owner.id,
|
||||
input.ownerPlanFeature ?? 'pro_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantUserPlan(t, owner.id, input.ownerPlanFeature);
|
||||
}
|
||||
if (input.actorPlan && actor.id !== owner.id) {
|
||||
await t.context.models.userFeature.add(
|
||||
actor.id,
|
||||
input.actorPlanFeature ?? 'pro_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantUserPlan(t, actor.id, input.actorPlanFeature);
|
||||
}
|
||||
|
||||
return { owner, actor, workspace };
|
||||
@@ -252,7 +325,7 @@ for (const matrixCase of byokManagementMatrix) {
|
||||
|
||||
test('byok service persists encrypted server keys and never returns plaintext', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const primary = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -325,7 +398,7 @@ test('byok service persists encrypted server keys and never returns plaintext',
|
||||
|
||||
test('byok service preserves server key fields during partial updates', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -381,7 +454,7 @@ test('byok service preserves server key fields during partial updates', async t
|
||||
|
||||
test('local leases are short lived and do not persist keys to server configs', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const before = Date.now();
|
||||
const lease = await t.context.byok.createLocalLease({
|
||||
@@ -486,7 +559,7 @@ test('local leases persist normalized custom endpoints', async t => {
|
||||
).get(() => true);
|
||||
t.teardown(() => customEndpointSupported.restore());
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const lease = await t.context.byok.createLocalLease({
|
||||
workspaceId: workspace.id,
|
||||
@@ -659,13 +732,10 @@ for (const matrixCase of byokProfileAvailabilityMatrix) {
|
||||
}
|
||||
|
||||
if (matrixCase.revokeOwnerPlan) {
|
||||
await t.context.models.userFeature.remove(owner.id, 'pro_plan_v1');
|
||||
await revokeUserPlan(t, owner.id);
|
||||
}
|
||||
if (matrixCase.revokeTeam) {
|
||||
await t.context.models.workspaceFeature.remove(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
);
|
||||
await revokeTeamPlan(t, workspace.id);
|
||||
}
|
||||
if (matrixCase.demoteActor) {
|
||||
await t.context.models.workspaceUser.set(
|
||||
@@ -695,7 +765,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const profiles = await t.context.byok.getProfiles({
|
||||
workspaceId: randomUUID(),
|
||||
@@ -707,7 +777,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
|
||||
|
||||
test('test key failure disables a saved key and success restores it', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -778,7 +848,7 @@ test('test key failure disables a saved key and success restores it', async t =>
|
||||
|
||||
test('local key test does not mutate saved server config', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -817,7 +887,7 @@ test('local key test does not mutate saved server config', async t => {
|
||||
|
||||
test('Gemini key test sends key in header and returns safe failure message', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
new Response(
|
||||
@@ -852,7 +922,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
|
||||
|
||||
test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
new Response('{}', { status: 200 })
|
||||
@@ -877,7 +947,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
|
||||
test('provider test failures do not return raw provider response body', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const cases = [
|
||||
{
|
||||
body: 'authorization: Bearer token=a+b%2F==',
|
||||
@@ -925,7 +995,7 @@ test('provider test failures do not return raw provider response body', async t
|
||||
|
||||
test('dispatch failure disables server BYOK key by provider id', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -956,7 +1026,7 @@ test('dispatch failure disables server BYOK key by provider id', async t => {
|
||||
|
||||
test('dispatch accounting ignores provider ids from another workspace hash', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const otherWorkspace = await t.context.models.workspace.create(user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -996,7 +1066,7 @@ test('dispatch accounting ignores provider ids from another workspace hash', asy
|
||||
|
||||
test('effective profiles use local lease before server keys and skip disabled keys', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const serverKey = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -1067,7 +1137,7 @@ test('effective profiles use local lease before server keys and skip disabled ke
|
||||
|
||||
test('capability warnings match server Gemini background coverage', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const emptySettings = await t.context.byok.getSettings(workspace.id, user.id);
|
||||
t.deepEqual(
|
||||
|
||||
@@ -732,7 +732,7 @@ test('should be able to chat with special image model', async t => {
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, sessionId, 'some-tag', [
|
||||
`https://example.com/${promptName}.jpg`,
|
||||
smallestPng,
|
||||
]);
|
||||
const ret3 = await chatWithImages(app, sessionId, messageId);
|
||||
t.is(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { AuthService } from '../../core/auth';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
|
||||
import {
|
||||
ContextCategories,
|
||||
@@ -101,6 +102,7 @@ type Context = {
|
||||
actionBridge: ActionRuntimeBridge;
|
||||
cronJobs: CopilotCronJobs;
|
||||
subscription: SubscriptionService;
|
||||
quotaState: QuotaStateService;
|
||||
};
|
||||
|
||||
const buildTurn = (
|
||||
@@ -199,6 +201,7 @@ test.before(async t => {
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
const subscription = module.get(SubscriptionService);
|
||||
const quotaState = module.get(QuotaStateService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
@@ -225,6 +228,7 @@ test.before(async t => {
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
t.context.subscription = subscription;
|
||||
t.context.quotaState = quotaState;
|
||||
|
||||
await module.initTestingDB();
|
||||
});
|
||||
@@ -2172,7 +2176,7 @@ test('model selection policy should resolve requested optional models consistent
|
||||
});
|
||||
|
||||
test('capability policy host should gate pro model requests by subscription status', async t => {
|
||||
const { subscription, module } = t.context;
|
||||
const { quotaState, subscription, module } = t.context;
|
||||
const capabilityPolicy = module.get(CapabilityPolicyHost);
|
||||
|
||||
const mockStatus = (status?: SubscriptionStatus) => {
|
||||
@@ -2181,6 +2185,10 @@ test('capability policy host should gate pro model requests by subscription stat
|
||||
// @ts-expect-error mock
|
||||
getSubscription: async () => (status ? { status } : null),
|
||||
}));
|
||||
Sinon.stub(quotaState, 'reconcileUserQuotaState').resolves({
|
||||
plan: status === SubscriptionStatus.Active ? 'pro' : 'free',
|
||||
flags: {},
|
||||
} as Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>);
|
||||
};
|
||||
|
||||
// payment disabled -> allow requested if in optional; pro not blocked
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { DocReader } from '../../core/doc';
|
||||
import type { AccessController } from '../../core/permission';
|
||||
import type { PermissionAccess } from '../../core/permission';
|
||||
import type { Models } from '../../models';
|
||||
import {
|
||||
LlmRequest,
|
||||
@@ -404,7 +404,7 @@ test('doc_read should return specific sync errors for unavailable docs', async t
|
||||
user: () => ({
|
||||
workspace: () => ({ doc: () => ({ can: async () => true }) }),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
for (const testCase of cases) {
|
||||
let docReaderCalled = false;
|
||||
@@ -447,7 +447,7 @@ test('document search tools should return sync error for local workspace', async
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -510,7 +510,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -542,7 +542,7 @@ test('doc_semantic_search should pass BYOK route context into embedding matches'
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -595,7 +595,7 @@ test('blob_read should return explicit error when attachment context is missing'
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const blobTool = createBlobReadTool(
|
||||
buildBlobContentGetter(ac, null).bind(null, {
|
||||
|
||||
@@ -57,6 +57,21 @@ function getSnapshot(timestamp: number = Date.now()): DocRecord {
|
||||
};
|
||||
}
|
||||
|
||||
test('history max age converts quota seconds to milliseconds', async t => {
|
||||
Sinon.restore();
|
||||
const options = m.get(DocStorageOptions);
|
||||
// @ts-expect-error private service boundary is asserted here
|
||||
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
|
||||
name: 'Pro',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
historyPeriod: 30,
|
||||
memberLimit: 1,
|
||||
});
|
||||
|
||||
t.is(await options.historyMaxAge('1'), 30_000);
|
||||
});
|
||||
|
||||
test('should create doc history if never created before', async t => {
|
||||
// @ts-expect-error private method
|
||||
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
|
||||
|
||||
@@ -273,16 +273,64 @@ e2e('should update comment work', async t => {
|
||||
t.truthy(result.updateComment);
|
||||
});
|
||||
|
||||
e2e('should update comment failed by another user', async t => {
|
||||
e2e('should update comment work by doc Editor', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Editor,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const result = await app.gql({
|
||||
query: updateCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: createResult.createComment.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test update' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.updateComment);
|
||||
});
|
||||
|
||||
e2e('should update comment failed without update permission', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
@@ -1145,15 +1193,79 @@ e2e('should update reply work when user is reply owner', async t => {
|
||||
t.truthy(result.updateReply);
|
||||
});
|
||||
|
||||
e2e('should update reply failed when user is not reply owner', async t => {
|
||||
e2e('should update reply work by doc Editor', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Editor,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createReplyResult = await app.gql({
|
||||
query: createReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
commentId: createResult.createComment.id,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const result = await app.gql({
|
||||
query: updateReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: createReplyResult.createReply.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test update' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.updateReply);
|
||||
});
|
||||
|
||||
e2e('should update reply failed without update permission', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
|
||||
@@ -28,37 +28,43 @@ e2e('should render doc share page with apple-itunes-app meta tag', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
e2e(
|
||||
e2e.serial(
|
||||
'should render doc share page without apple-itunes-app meta tag when selfhosted',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error override
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
await using app = await createApp();
|
||||
try {
|
||||
await using app = await createApp();
|
||||
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
// set public to true
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
public: true,
|
||||
});
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
// set public to true
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
public: true,
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'text/html; charset=utf-8');
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'text/html; charset=utf-8');
|
||||
|
||||
t.notRegex(
|
||||
res.text,
|
||||
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
|
||||
);
|
||||
t.notRegex(
|
||||
res.text,
|
||||
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -69,6 +69,64 @@ e2e('should get recently updated docs', async t => {
|
||||
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
|
||||
});
|
||||
|
||||
e2e('should filter recently updated docs by doc read permission', async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.createUser();
|
||||
await app.login(member);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
});
|
||||
|
||||
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: privateSnapshot.id,
|
||||
title: 'private-doc',
|
||||
defaultRole: DocRole.None,
|
||||
});
|
||||
|
||||
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
const publicDoc = await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: publicSnapshot.id,
|
||||
title: 'public-doc',
|
||||
defaultRole: DocRole.None,
|
||||
public: true,
|
||||
});
|
||||
|
||||
await app.switchUser(member);
|
||||
const {
|
||||
workspace: { recentlyUpdatedDocs },
|
||||
} = await app.gql({
|
||||
query: getRecentlyUpdatedDocsQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
pagination: {
|
||||
first: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(recentlyUpdatedDocs.totalCount, 1);
|
||||
t.deepEqual(
|
||||
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
|
||||
[publicDoc.docId]
|
||||
);
|
||||
});
|
||||
|
||||
e2e(
|
||||
'should get doc with public attribute when doc snapshot not exists',
|
||||
async t => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
listNotificationsQuery,
|
||||
MentionNotificationBodyType,
|
||||
mentionUserMutation,
|
||||
notificationCountQuery,
|
||||
NotificationObjectType,
|
||||
NotificationType,
|
||||
readAllNotificationsMutation,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
async function init() {
|
||||
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
|
||||
},
|
||||
});
|
||||
}
|
||||
const count = await app.gql({
|
||||
query: notificationCountQuery,
|
||||
});
|
||||
t.is(count.currentUser!.notificationCount, 0);
|
||||
const socket = await createRealtimeClient(app, member);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const count = await realtimeRequest(socket, 'notification.count.get', {});
|
||||
t.is(count.count, 0);
|
||||
|
||||
// read again should work
|
||||
for (const notification of notifications) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
RealtimeAck,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
} from '@affine/realtime';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import type { Response } from 'supertest';
|
||||
|
||||
import type { MockedUser } from '../mocks';
|
||||
import type { TestingApp } from './create-app';
|
||||
|
||||
const REALTIME_CLIENT_VERSION = '0.26.0';
|
||||
const WS_TIMEOUT_MS = 5_000;
|
||||
|
||||
function cookieHeader(res: Response) {
|
||||
return (res.get('Set-Cookie') ?? [])
|
||||
.map(cookie => cookie.split(';')[0])
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
) {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnect(socket: SocketIOClient) {
|
||||
if (socket.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
'realtime socket connect'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
|
||||
const login = await app.login(user);
|
||||
const socket = io(app.url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
extraHeaders: {
|
||||
cookie: cookieHeader(login),
|
||||
},
|
||||
});
|
||||
await waitForConnect(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
export async function realtimeRequest<Op extends RealtimeRequestName>(
|
||||
socket: SocketIOClient,
|
||||
op: Op,
|
||||
input: RealtimeRequestInputOf<Op>
|
||||
): Promise<RealtimeRequestOutputOf<Op>> {
|
||||
const ack = await withTimeout(
|
||||
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
|
||||
socket.emit(
|
||||
'realtime:request',
|
||||
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
|
||||
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
|
||||
);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`realtime request ${op}`
|
||||
);
|
||||
|
||||
if ('error' in ack) {
|
||||
throw new Error(`${ack.error.name}: ${ack.error.message}`);
|
||||
}
|
||||
|
||||
return ack.data;
|
||||
}
|
||||
@@ -15,9 +15,18 @@ import {
|
||||
R2StorageProvider,
|
||||
} from '../../../base/storage/providers/r2';
|
||||
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
|
||||
import { WorkspaceBlobStorage } from '../../../core/storage';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import {
|
||||
CommentAttachmentStorage,
|
||||
WorkspaceBlobStorage,
|
||||
} from '../../../core/storage';
|
||||
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
|
||||
import { R2UploadController } from '../../../core/storage/r2-proxy';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { app, e2e, Mockers } from '../test';
|
||||
|
||||
class MockR2Provider extends R2StorageProvider {
|
||||
@@ -160,6 +169,8 @@ async function setBlobStorage(storage: StorageProviderConfig) {
|
||||
configFactory.override({ storages: { blob: { storage } } });
|
||||
const blobStorage = app.get(WorkspaceBlobStorage);
|
||||
await blobStorage.onConfigInit();
|
||||
const commentAttachmentStorage = app.get(CommentAttachmentStorage);
|
||||
await commentAttachmentStorage.onConfigInit();
|
||||
const controller = app.get(R2UploadController);
|
||||
// reset cached provider in controller
|
||||
(controller as any).provider = null;
|
||||
@@ -245,7 +256,13 @@ async function getBlobUploadPartUrl(
|
||||
}
|
||||
|
||||
async function setupWorkspace() {
|
||||
const owner = await app.signup({ feature: 'pro_plan_v1' });
|
||||
const owner = await app.signup();
|
||||
await app.get(EntitlementService).upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
return { owner, workspace };
|
||||
}
|
||||
@@ -435,7 +452,13 @@ e2e(
|
||||
e2e(
|
||||
'should still fallback to graphql when provider does not support presign',
|
||||
async t => {
|
||||
await setBlobStorage(defaultBlobStorage);
|
||||
await setBlobStorage({
|
||||
provider: 'fs',
|
||||
bucket: 'test-fallback-bucket',
|
||||
config: {
|
||||
path: '/tmp/affine-r2-proxy-test',
|
||||
},
|
||||
});
|
||||
const { workspace } = await setupWorkspace();
|
||||
const buffer = Buffer.from('graph');
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import {
|
||||
Config,
|
||||
ConfigFactory,
|
||||
type StorageProviderConfig,
|
||||
} from '../../../base';
|
||||
import { CommentAttachmentStorage } from '../../../core/storage';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
@@ -21,6 +26,11 @@ e2e.afterEach.always(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
|
||||
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
|
||||
await app.get(CommentAttachmentStorage).onConfigInit();
|
||||
}
|
||||
|
||||
// #region comment attachment
|
||||
|
||||
e2e(
|
||||
@@ -61,35 +71,50 @@ e2e(
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should get comment attachment body', async t => {
|
||||
e2e.serial('should get comment attachment body', async t => {
|
||||
const defaultBlobStorage = structuredClone(
|
||||
app.get(Config).storages.blob.storage
|
||||
);
|
||||
await useCommentAttachmentBlobStorage({
|
||||
provider: 'fs',
|
||||
bucket: 'test-comment-attachment',
|
||||
config: {
|
||||
path: '/tmp/affine-test-comment-attachment',
|
||||
},
|
||||
});
|
||||
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
await app.login(owner);
|
||||
|
||||
const docId = randomUUID();
|
||||
const key = randomUUID();
|
||||
const attachment = app.get(CommentAttachmentStorage);
|
||||
await attachment.put(
|
||||
workspace.id,
|
||||
docId,
|
||||
key,
|
||||
'test.txt',
|
||||
Buffer.from('test'),
|
||||
owner.id
|
||||
);
|
||||
try {
|
||||
const docId = randomUUID();
|
||||
const key = randomUUID();
|
||||
const attachment = app.get(CommentAttachmentStorage);
|
||||
await attachment.put(
|
||||
workspace.id,
|
||||
docId,
|
||||
key,
|
||||
'test.txt',
|
||||
Buffer.from('test'),
|
||||
owner.id
|
||||
);
|
||||
|
||||
const res = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
|
||||
);
|
||||
const res = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
|
||||
);
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.is(res.headers['content-type'], 'text/plain');
|
||||
t.is(res.headers['content-length'], '4');
|
||||
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
|
||||
t.regex(
|
||||
res.headers['last-modified'],
|
||||
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
|
||||
);
|
||||
t.is(res.text, 'test');
|
||||
t.is(res.status, 200);
|
||||
t.is(res.headers['content-type'], 'text/plain');
|
||||
t.is(res.headers['content-length'], '4');
|
||||
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
|
||||
t.regex(
|
||||
res.headers['last-modified'],
|
||||
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
|
||||
);
|
||||
t.is(res.text, 'test');
|
||||
} finally {
|
||||
await useCommentAttachmentBlobStorage(defaultBlobStorage);
|
||||
}
|
||||
});
|
||||
|
||||
e2e('should get comment attachment redirect url', async t => {
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
acceptInviteByInviteIdMutation,
|
||||
approveWorkspaceTeamMemberMutation,
|
||||
createInviteLinkMutation,
|
||||
deleteBlobMutation,
|
||||
getInviteInfoQuery,
|
||||
getMembersByWorkspaceIdQuery,
|
||||
inviteByEmailsMutation,
|
||||
leaveWorkspaceMutation,
|
||||
releaseDeletedBlobsMutation,
|
||||
revokeMemberPermissionMutation,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
WorkspaceMemberSource,
|
||||
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { Models } from '../../../models';
|
||||
import { FeatureConfigs } from '../../../models/common/feature';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../../core/permission';
|
||||
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const TWO_BILLION_BYTES = 2_000_000_000;
|
||||
|
||||
async function createWorkspace() {
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
@@ -35,6 +43,23 @@ async function createWorkspace() {
|
||||
};
|
||||
}
|
||||
|
||||
async function grantTeamPlan(workspaceId: string, quantity: number) {
|
||||
await app.get(EntitlementService).upsertFromCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeTeamPlan(workspaceId: string) {
|
||||
await app.get(EntitlementService).revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
});
|
||||
}
|
||||
|
||||
e2e('should invite a user', async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const u2 = await app.create(Mockers.User);
|
||||
@@ -91,19 +116,16 @@ e2e('should invite a user', async t => {
|
||||
e2e('should re-check seat when accepting an email invitation', async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const member = await app.create(Mockers.User);
|
||||
await app.create(Mockers.TeamWorkspace, {
|
||||
id: workspace.id,
|
||||
quantity: 4,
|
||||
});
|
||||
await grantTeamPlan(workspace.id, 12);
|
||||
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }).map(async () => {
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const invite = await app.gql({
|
||||
@@ -116,10 +138,10 @@ e2e('should re-check seat when accepting an email invitation', async t => {
|
||||
|
||||
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
||||
workspaceId: workspace.id,
|
||||
quantity: 4,
|
||||
quantity: 12,
|
||||
});
|
||||
|
||||
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
|
||||
await revokeTeamPlan(workspace.id);
|
||||
|
||||
await app.login(member);
|
||||
await t.throwsAsync(
|
||||
@@ -147,24 +169,6 @@ e2e.serial(
|
||||
async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const member = await app.create(Mockers.User);
|
||||
const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota;
|
||||
const lifetimeStorageQuota =
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota;
|
||||
|
||||
FeatureConfigs.free_plan_v1.configs.storageQuota = 1;
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2;
|
||||
t.teardown(() => {
|
||||
FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota;
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota =
|
||||
lifetimeStorageQuota;
|
||||
});
|
||||
|
||||
await app.models.userFeature.switchQuota(
|
||||
owner.id,
|
||||
'lifetime_pro_plan_v1',
|
||||
'test setup'
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const invite = await app.gql({
|
||||
query: inviteByEmailsMutation,
|
||||
@@ -174,26 +178,26 @@ e2e.serial(
|
||||
},
|
||||
});
|
||||
|
||||
await app.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key: 'overflow-blob',
|
||||
mime: 'application/octet-stream',
|
||||
size: 2,
|
||||
status: 'completed',
|
||||
uploadId: null,
|
||||
});
|
||||
|
||||
await app.eventBus.emitAsync('user.subscription.canceled', {
|
||||
userId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
});
|
||||
const overflowBlobKeys = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `overflow-blob-${index}`
|
||||
);
|
||||
await Promise.all(
|
||||
overflowBlobKeys.map(key =>
|
||||
app.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key,
|
||||
mime: 'application/octet-stream',
|
||||
size: TWO_BILLION_BYTES,
|
||||
status: 'completed',
|
||||
uploadId: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
t.true(
|
||||
await app.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||
.isReadonly
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
@@ -216,26 +220,13 @@ e2e.serial(
|
||||
t.is(pendingInvite.status, WorkspaceMemberStatus.Pending);
|
||||
|
||||
await app.login(owner);
|
||||
await app.gql({
|
||||
query: deleteBlobMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
key: 'overflow-blob',
|
||||
permanently: false,
|
||||
},
|
||||
});
|
||||
await app.gql({
|
||||
query: releaseDeletedBlobsMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
for (const key of overflowBlobKeys) {
|
||||
await app.models.blob.delete(workspace.id, key, true);
|
||||
}
|
||||
|
||||
t.false(
|
||||
await app.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||
.isReadonly
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
@@ -393,39 +384,31 @@ e2e('should support pagination for member', async t => {
|
||||
userId: u2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 2);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 2);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 0);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 0);
|
||||
});
|
||||
|
||||
e2e('should limit member count correctly', async t => {
|
||||
@@ -441,17 +424,15 @@ e2e('should limit member count correctly', async t => {
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 11);
|
||||
t.is(result.workspace.members.length, 10);
|
||||
t.is(result.memberCount, 11);
|
||||
t.is(result.members.length, 10);
|
||||
});
|
||||
|
||||
e2e('should get invite link info with status', async t => {
|
||||
@@ -596,10 +577,7 @@ e2e(
|
||||
'should invite by link and send review request notification over quota limit',
|
||||
async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
await app.create(Mockers.TeamWorkspace, {
|
||||
id: workspace.id,
|
||||
quantity: 3,
|
||||
});
|
||||
await grantTeamPlan(workspace.id, 3);
|
||||
|
||||
await app.login(owner);
|
||||
const { createInviteLink } = await app.gql({
|
||||
@@ -639,10 +617,7 @@ e2e(
|
||||
name: faker.internet.displayName({ firstName: 'Lucy' }),
|
||||
});
|
||||
const user2 = await app.create(Mockers.User, {
|
||||
email: faker.internet.email({
|
||||
firstName: 'Jeanne',
|
||||
lastName: 'Doe',
|
||||
}),
|
||||
email: `jeanne_doe.${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
@@ -653,38 +628,54 @@ e2e(
|
||||
userId: user2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].email, user2.email);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, user2.email);
|
||||
|
||||
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
|
||||
const pendingUser = await app.create(Mockers.User, {
|
||||
email: pendingEmail,
|
||||
});
|
||||
await app
|
||||
.get(Models)
|
||||
.workspaceUser.set(
|
||||
workspace.id,
|
||||
pendingUser.id,
|
||||
ModelWorkspaceRole.Collaborator,
|
||||
{
|
||||
status: PrismaWorkspaceMemberStatus.Pending,
|
||||
source: WorkspaceMemberSource.Email,
|
||||
}
|
||||
);
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'pending_search',
|
||||
});
|
||||
t.is(result.memberCount, 4);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, pendingEmail);
|
||||
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
revokePublicPageMutation,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { QuotaService } from '../../../core/quota/service';
|
||||
import { WorkspaceRole } from '../../../models';
|
||||
@@ -98,7 +99,31 @@ const revokeMember = async (workspaceId: string, userId: string) => {
|
||||
return revokeMember;
|
||||
};
|
||||
|
||||
e2e('should set new invited users to AllocatingSeat', async t => {
|
||||
const cancelTeamWorkspace = async (workspaceId: string) => {
|
||||
const db = app.get(PrismaClient);
|
||||
await db.entitlement.updateMany({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: 'team',
|
||||
},
|
||||
data: { status: 'revoked' },
|
||||
});
|
||||
await db.subscription.updateMany({
|
||||
where: {
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
},
|
||||
data: { status: 'canceled' },
|
||||
});
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
};
|
||||
|
||||
e2e('should set new invited users to waiting-seat status', async t => {
|
||||
const { owner, workspace } = await createTeamWorkspace();
|
||||
await app.login(owner);
|
||||
|
||||
@@ -117,7 +142,7 @@ e2e('should set new invited users to AllocatingSeat', async t => {
|
||||
const invitationInfo = await getInvitationInfo(
|
||||
result.inviteMembers[0].inviteId!
|
||||
);
|
||||
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
|
||||
t.is(invitationInfo.status, WorkspaceMemberStatus.NeedMoreSeat);
|
||||
});
|
||||
|
||||
e2e('should allocate seats', async t => {
|
||||
@@ -151,11 +176,11 @@ e2e('should allocate seats', async t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
members.find(m => m.user.id === u1.id)?.status,
|
||||
members.find(m => m.user?.id === u1.id)?.status,
|
||||
WorkspaceMemberStatus.Pending
|
||||
);
|
||||
t.is(
|
||||
members.find(m => m.user.id === u2.id)?.status,
|
||||
members.find(m => m.user?.id === u2.id)?.status,
|
||||
WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
|
||||
@@ -201,11 +226,11 @@ e2e('should set all rests to NeedMoreSeat', async t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
members.find(m => m.user.id === u2.id)?.status,
|
||||
members.find(m => m.user?.id === u2.id)?.status,
|
||||
WorkspaceMemberStatus.NeedMoreSeat
|
||||
);
|
||||
t.is(
|
||||
members.find(m => m.user.id === u3.id)?.status,
|
||||
members.find(m => m.user?.id === u3.id)?.status,
|
||||
WorkspaceMemberStatus.NeedMoreSeat
|
||||
);
|
||||
});
|
||||
@@ -237,11 +262,7 @@ e2e(
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
|
||||
first: 20,
|
||||
@@ -265,11 +286,7 @@ e2e(
|
||||
async t => {
|
||||
const { workspace, owner, admin } = await createTeamWorkspace();
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||
t.false(
|
||||
@@ -306,11 +323,7 @@ e2e(
|
||||
await app.login(owner);
|
||||
await publishDoc(workspace.id, 'published-doc');
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||
t.true(
|
||||
@@ -325,7 +338,7 @@ e2e(
|
||||
);
|
||||
|
||||
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
|
||||
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
|
||||
await t.throwsAsync(revokePublicDoc(workspace.id, 'published-doc'));
|
||||
|
||||
const quota = await app
|
||||
.get(QuotaService)
|
||||
|
||||
@@ -27,6 +27,16 @@ export class MockTeamWorkspace extends Mocker<
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
await this.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
status: 'active',
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.workspaceFeature.create({
|
||||
data: {
|
||||
|
||||
@@ -45,6 +45,55 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
const runtimeStateColumns = await this.db.$queryRaw<
|
||||
Array<{ exists: boolean }>
|
||||
>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_runtime_states'
|
||||
AND column_name = 'known'
|
||||
) AS "exists"
|
||||
`;
|
||||
if (runtimeStateColumns[0]?.exists) {
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO workspace_runtime_states (
|
||||
workspace_id,
|
||||
known,
|
||||
readonly,
|
||||
readonly_reasons,
|
||||
last_reconciled_at,
|
||||
stale_after,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
known = true,
|
||||
readonly = false,
|
||||
readonly_reasons = ARRAY[]::TEXT[],
|
||||
last_reconciled_at = now(),
|
||||
stale_after = NULL,
|
||||
updated_at = now()
|
||||
`;
|
||||
} else {
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO workspace_runtime_states (
|
||||
workspace_id,
|
||||
readonly,
|
||||
readonly_reasons,
|
||||
stale_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
readonly = false,
|
||||
readonly_reasons = ARRAY[]::TEXT[],
|
||||
stale_at = NULL,
|
||||
updated_at = now()
|
||||
`;
|
||||
}
|
||||
|
||||
// create a rootDoc snapshot
|
||||
if (snapshot) {
|
||||
|
||||
@@ -73,6 +73,24 @@ test('should set doc user role', async t => {
|
||||
t.is(role?.type, DocRole.Manager);
|
||||
});
|
||||
|
||||
test('should batch update existing doc user roles', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
await models.docUser.set(workspace.id, docId, user.id, DocRole.Reader);
|
||||
const count = await models.docUser.batchSetUserRoles(
|
||||
workspace.id,
|
||||
docId,
|
||||
[user.id],
|
||||
DocRole.Editor
|
||||
);
|
||||
const role = await models.docUser.get(workspace.id, docId, user.id);
|
||||
|
||||
t.is(count, 1);
|
||||
t.is(role?.type, DocRole.Editor);
|
||||
});
|
||||
|
||||
test('should not allow setting doc owner through setDocUserRole', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
@@ -96,6 +114,23 @@ test('should delete doc user role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should delete doc grants by user id', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
|
||||
await models.docUser.deleteByUserId(user.id);
|
||||
|
||||
t.is(await models.docUser.get(workspace.id, docId, user.id), null);
|
||||
t.is(
|
||||
await db.docGrant.count({
|
||||
where: { principalType: 'user', principalId: user.id },
|
||||
}),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should paginate doc user roles', async t => {
|
||||
const workspace = await create();
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
|
||||
import { AvailableUserFeatureConfig } from '../../core/features/types';
|
||||
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
|
||||
import { Feature } from '../../models/common/feature';
|
||||
import { createTestingModule, TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
model: UserFeatureModel;
|
||||
resolver: AdminFeatureManagementResolver;
|
||||
u1: User;
|
||||
}
|
||||
|
||||
@@ -16,6 +20,7 @@ test.before(async t => {
|
||||
const module = await createTestingModule({});
|
||||
|
||||
t.context.model = module.get(UserFeatureModel);
|
||||
t.context.resolver = module.get(AdminFeatureManagementResolver);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
@@ -31,6 +36,21 @@ test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('configurable user features exclude commercial projection features', t => {
|
||||
const config = new AvailableUserFeatureConfig();
|
||||
|
||||
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
|
||||
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
|
||||
});
|
||||
|
||||
test('admin feature resolver rejects commercial projection features', async t => {
|
||||
await t.throwsAsync(
|
||||
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
|
||||
{ message: /not configurable/ }
|
||||
);
|
||||
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
|
||||
});
|
||||
|
||||
test('should get null if user feature not found', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
const userFeature = await model.get(u1.id, 'ai_early_access');
|
||||
@@ -39,12 +59,14 @@ test('should get null if user feature not found', async t => {
|
||||
|
||||
test('should get user feature', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const userFeature = await model.get(u1.id, 'free_plan_v1');
|
||||
t.is(userFeature?.name, 'free_plan_v1');
|
||||
});
|
||||
|
||||
test('should get user quota', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const userQuota = await model.getQuota(u1.id);
|
||||
t.snapshot(userQuota?.configs, 'free plan');
|
||||
});
|
||||
@@ -52,6 +74,7 @@ test('should get user quota', async t => {
|
||||
test('should list user features', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.like(await model.list(u1.id), ['free_plan_v1']);
|
||||
});
|
||||
|
||||
@@ -68,6 +91,7 @@ test('should list user features by type', async t => {
|
||||
test('should directly test user feature existence', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.true(await model.has(u1.id, 'free_plan_v1'));
|
||||
t.false(await model.has(u1.id, 'ai_early_access'));
|
||||
});
|
||||
@@ -112,6 +136,7 @@ test('should switch user quota', async t => {
|
||||
test('should not switch user quota if the new quota is the same as the current one', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
|
||||
|
||||
// @ts-expect-error private
|
||||
@@ -135,6 +160,7 @@ test('should use pro plan as free for selfhost instance', async t => {
|
||||
registered: true,
|
||||
});
|
||||
|
||||
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const quota = await models.userFeature.getQuota(u1.id);
|
||||
t.snapshot(
|
||||
quota?.configs,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Workspace } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
|
||||
import {
|
||||
FeatureType,
|
||||
UserModel,
|
||||
@@ -12,6 +13,7 @@ import { createTestingModule, type TestingModule } from '../utils';
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
model: WorkspaceFeatureModel;
|
||||
resolver: AdminWorkspaceResolver;
|
||||
ws: Workspace;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ test.before(async t => {
|
||||
const module = await createTestingModule({});
|
||||
|
||||
t.context.model = module.get(WorkspaceFeatureModel);
|
||||
t.context.resolver = module.get(AdminWorkspaceResolver);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
@@ -44,6 +47,17 @@ test('should get null if workspace feature not found', async t => {
|
||||
t.is(userFeature, null);
|
||||
});
|
||||
|
||||
test('admin workspace update changes workspace flags', async t => {
|
||||
await t.context.resolver.adminUpdateWorkspace({
|
||||
id: t.context.ws.id,
|
||||
name: 'updated',
|
||||
});
|
||||
t.is(
|
||||
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
|
||||
'updated'
|
||||
);
|
||||
});
|
||||
|
||||
test('should directly test workspace feature existence', async t => {
|
||||
const { model, ws } = t.context;
|
||||
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
|
||||
import {
|
||||
DocRole,
|
||||
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
|
||||
PermissionProjectionModel,
|
||||
permissionProjectionTriggerErrorCategory,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../models';
|
||||
import { createModule } from '../create-module';
|
||||
import { Mockers } from '../mocks';
|
||||
|
||||
const module = await createModule({});
|
||||
const db = module.get(PrismaClient);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
class TestPermissionProjectionModel extends PermissionProjectionModel {
|
||||
constructor(private readonly fakeDb: unknown) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override get db() {
|
||||
return this.fakeDb as never;
|
||||
}
|
||||
}
|
||||
|
||||
let appliedPermissionProjectionTriggerFunctionUpdates = false;
|
||||
async function applyPermissionProjectionTriggerFunctionUpdates() {
|
||||
if (appliedPermissionProjectionTriggerFunctionUpdates) {
|
||||
return;
|
||||
}
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260512133700_workspace_runtime_states/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
for (const name of [
|
||||
'affine_permission_project_new_workspace_member',
|
||||
'affine_permission_project_new_workspace_invitation',
|
||||
'affine_permission_project_new_doc_access_policy',
|
||||
'affine_permission_project_new_doc_grant',
|
||||
]) {
|
||||
const sql = migration.match(
|
||||
new RegExp(
|
||||
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
|
||||
)
|
||||
)?.[0];
|
||||
if (!sql) {
|
||||
throw new Error(`Missing migration function ${name}`);
|
||||
}
|
||||
await db.$executeRawUnsafe(sql);
|
||||
}
|
||||
appliedPermissionProjectionTriggerFunctionUpdates = true;
|
||||
}
|
||||
|
||||
async function hasCurrentWorkspaceInvitationColumns() {
|
||||
const rows = await db.$queryRaw<{ columnName: string }[]>`
|
||||
SELECT column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_invitations'
|
||||
AND column_name IN ('requested_role', 'status', 'kind')
|
||||
`;
|
||||
return rows.length === 3;
|
||||
}
|
||||
|
||||
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
|
||||
const queryResults = [
|
||||
[{ count: 1n }],
|
||||
[{ count: 2n }],
|
||||
[{ count: 3n }],
|
||||
[{ count: 4n }],
|
||||
[{ count: 5n }],
|
||||
[{ count: 6n }],
|
||||
[{ count: 7n }],
|
||||
[{ count: 8n }],
|
||||
[{ count: 9n }],
|
||||
[{ count: 10n }],
|
||||
[
|
||||
{ category: 'legacy_doc_external_row', count: 11n },
|
||||
{ category: 'doc_default_owner', count: 12n },
|
||||
],
|
||||
];
|
||||
const model = new TestPermissionProjectionModel({
|
||||
$queryRaw: async () => queryResults.shift(),
|
||||
});
|
||||
|
||||
t.deepEqual(await model.checkLegacyProjection(), {
|
||||
oldWorkspacePolicyMismatch: 1,
|
||||
oldAcceptedMemberMismatch: 2,
|
||||
extraProjectedMember: 3,
|
||||
oldInvitationMismatch: 4,
|
||||
extraProjectedInvitation: 5,
|
||||
oldDocGrantMismatch: 6,
|
||||
extraProjectedDocGrant: 7,
|
||||
oldDocPolicyMismatch: 8,
|
||||
extraProjectedDocPolicy: 9,
|
||||
runtimeStateMissing: 0,
|
||||
runtimeStateMismatch: 0,
|
||||
ownerConflict: 10,
|
||||
oldNewDecisionMismatch: 0,
|
||||
invalidLegacyRows: {
|
||||
legacy_doc_external_row: 11,
|
||||
doc_default_owner: 12,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
|
||||
const executed: unknown[] = [];
|
||||
let transactionOptions: unknown;
|
||||
const model = new TestPermissionProjectionModel({
|
||||
$transaction: async (
|
||||
callback: (tx: unknown) => Promise<void>,
|
||||
options: unknown
|
||||
) => {
|
||||
transactionOptions = options;
|
||||
await callback({
|
||||
$executeRaw: async (query: unknown) => {
|
||||
executed.push(query);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await model.backfillLegacyProjection();
|
||||
|
||||
t.is(executed.length, 11);
|
||||
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
|
||||
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
|
||||
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
|
||||
'owner_conflict',
|
||||
'invalid_legacy_role',
|
||||
'foreign_key_missing',
|
||||
'projection_recursion_guard_missing',
|
||||
'unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
test('permission projection migration uses non-recursive origin guard', t => {
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260512133700_workspace_runtime_states/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const guardBody = migration.match(
|
||||
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
|
||||
)?.[0];
|
||||
|
||||
t.truthy(guardBody);
|
||||
t.true(
|
||||
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
|
||||
);
|
||||
t.false(
|
||||
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
|
||||
);
|
||||
t.truthy(
|
||||
migration.match(
|
||||
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger maps legacy workspace permission rows', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const [admin, pending] = await module.create(Mockers.User, 2);
|
||||
|
||||
await db.workspaceUserRole.createMany({
|
||||
data: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId: admin.id,
|
||||
type: WorkspaceRole.Admin,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId: pending.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await db.workspaceMember.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: admin.id,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_inviteeUserId: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: pending.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(member.role, 'admin');
|
||||
t.is(invitation.requestedRole, 'member');
|
||||
t.is(invitation.status, 'pending');
|
||||
});
|
||||
|
||||
test('permission projection trigger maps legacy doc policy rows', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
await db.workspaceDoc.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'public-doc',
|
||||
public: true,
|
||||
defaultRole: DocRole.Reader,
|
||||
},
|
||||
});
|
||||
|
||||
const policy = await db.docAccessPolicy.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'public-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(policy.visibility, 'public');
|
||||
t.is(policy.publicRole, 'external');
|
||||
t.is(policy.memberDefaultRole, 'reader');
|
||||
});
|
||||
|
||||
async function hasDocGrantLegacyProjectionColumns() {
|
||||
const rows = await db.$queryRaw<{ columnName: string }[]>`
|
||||
SELECT column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'doc_grants'
|
||||
AND column_name IN (
|
||||
'legacy_workspace_id',
|
||||
'legacy_doc_id',
|
||||
'legacy_user_id'
|
||||
)
|
||||
`;
|
||||
return rows.length === 3;
|
||||
}
|
||||
|
||||
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
|
||||
if (!(await hasDocGrantLegacyProjectionColumns())) {
|
||||
t.false(
|
||||
Boolean(process.env.CI),
|
||||
'current local test database predates doc_grants legacy columns'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await db.workspaceDocUserRole.createMany({
|
||||
data: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'valid-grant',
|
||||
userId: user.id,
|
||||
type: DocRole.Reader,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'dirty-external',
|
||||
userId: user.id,
|
||||
type: DocRole.External,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'dirty-none',
|
||||
userId: user.id,
|
||||
type: DocRole.None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const grants = await db.docGrant.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
principalId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
docId: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
t.deepEqual(
|
||||
grants.map(grant => [grant.docId, grant.role]),
|
||||
[['valid-grant', 'reader']]
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const member = await db.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role: 'member',
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await db.workspaceMember.update({
|
||||
where: { id: member.id },
|
||||
data: { state: 'suspended' },
|
||||
});
|
||||
|
||||
t.is(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
|
||||
if (!(await hasCurrentWorkspaceInvitationColumns())) {
|
||||
t.false(
|
||||
Boolean(process.env.CI),
|
||||
'current local test database predates workspace invitation projection columns'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const [invitation] = await db.$queryRaw<{ id: string }[]>`
|
||||
INSERT INTO workspace_invitations (
|
||||
workspace_id,
|
||||
invitee_user_id,
|
||||
requested_role,
|
||||
status,
|
||||
kind
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${user.id},
|
||||
'member',
|
||||
'pending',
|
||||
'email'
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
t.is(
|
||||
(
|
||||
await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
).status,
|
||||
'Pending'
|
||||
);
|
||||
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_invitations
|
||||
SET status = 'declined'
|
||||
WHERE id = ${invitation.id}
|
||||
`;
|
||||
|
||||
t.is(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
await db.workspaceDoc.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
public: true,
|
||||
defaultRole: DocRole.Reader,
|
||||
mode: 1,
|
||||
blocked: true,
|
||||
title: 'Title',
|
||||
summary: 'Summary',
|
||||
publishedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await db.docAccessPolicy.delete({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const doc = await db.workspaceDoc.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(doc.public, false);
|
||||
t.is(doc.defaultRole, DocRole.Manager);
|
||||
t.is(doc.publishedAt, null);
|
||||
t.is(doc.mode, 1);
|
||||
t.is(doc.blocked, true);
|
||||
t.is(doc.title, 'Title');
|
||||
t.is(doc.summary, 'Summary');
|
||||
});
|
||||
|
||||
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await db.docGrant.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'user',
|
||||
principalId: user.id,
|
||||
role: 'reader',
|
||||
},
|
||||
});
|
||||
await db.docGrant.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'group',
|
||||
principalId: user.id,
|
||||
role: 'manager',
|
||||
},
|
||||
});
|
||||
await db.docGrant.delete({
|
||||
where: {
|
||||
workspaceId_docId_principalType_principalId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'group',
|
||||
principalId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(legacyGrant.type, DocRole.Reader);
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel parses trigger error metric category', t => {
|
||||
t.is(
|
||||
permissionProjectionTriggerErrorCategory(
|
||||
new Error('permission_projection_error:owner_conflict:duplicate owner')
|
||||
),
|
||||
'owner_conflict'
|
||||
);
|
||||
t.is(
|
||||
permissionProjectionTriggerErrorCategory(
|
||||
new Error('permission_projection_error:unexpected:nope')
|
||||
),
|
||||
'unknown'
|
||||
);
|
||||
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
|
||||
});
|
||||
|
||||
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
|
||||
const checker = new PermissionProjectionChecker(
|
||||
{
|
||||
workspace: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
$queryRaw: async () => [
|
||||
{
|
||||
category: 'active_member_doc',
|
||||
workspaceId: 'w1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
workspaceActions: null,
|
||||
docActions: ['Doc.Read'],
|
||||
},
|
||||
{
|
||||
category: 'explicit_doc_grant',
|
||||
workspaceId: 'w1',
|
||||
docId: 'doc2',
|
||||
userId: 'u1',
|
||||
workspaceActions: null,
|
||||
docActions: ['Doc.Read'],
|
||||
},
|
||||
{
|
||||
category: 'workspace_invitation',
|
||||
workspaceId: 'w1',
|
||||
docId: null,
|
||||
userId: 'u2',
|
||||
workspaceActions: ['Workspace.Read'],
|
||||
docActions: null,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
permissionProjection: {
|
||||
checkLegacyProjection: async () => ({}),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
load: async (input: { docs?: [{ docId: string }] }) => ({
|
||||
version: 1,
|
||||
workspace: { marker: 'legacy' },
|
||||
docs: input.docs
|
||||
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
|
||||
: [],
|
||||
}),
|
||||
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
|
||||
version: 1,
|
||||
workspace: { marker: input.docs ? 'legacy' : 'projection' },
|
||||
docs: input.docs
|
||||
? [
|
||||
{
|
||||
docId: input.docs[0].docId,
|
||||
marker:
|
||||
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
evaluate: (input: unknown) => input,
|
||||
} as never
|
||||
);
|
||||
|
||||
t.deepEqual(await checker.checkLegacyProjection(), {
|
||||
oldNewDecisionMismatch: 2,
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,22 @@ test('should not get inactive workspace role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should not activate a missing workspace invitation', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await t.throwsAsync(
|
||||
models.workspaceUser.setStatus(
|
||||
workspace.id,
|
||||
user.id,
|
||||
WorkspaceMemberStatus.Accepted
|
||||
),
|
||||
{ message: 'Cannot activate a missing workspace invitation.' }
|
||||
);
|
||||
|
||||
t.is(await models.workspaceUser.get(workspace.id, user.id), null);
|
||||
});
|
||||
|
||||
test('should update user role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
@@ -215,6 +231,114 @@ test('should delete workspace user role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should delete legacy-only external workspace user role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
|
||||
|
||||
await models.workspaceUser.delete(workspace.id, u1.id);
|
||||
|
||||
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
|
||||
});
|
||||
|
||||
test('should convert existing workspace user role to legacy-only external role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
const role = await models.workspaceUser.get(workspace.id, u1.id);
|
||||
t.is(role?.type, WorkspaceRole.External);
|
||||
t.is(
|
||||
await db.workspaceMember.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
state: 'active',
|
||||
},
|
||||
}),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should backfill legacy permission id for new workspace member writes', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
|
||||
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const member = await db.workspaceMember.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(member.legacyPermissionId, legacyRole.id);
|
||||
});
|
||||
|
||||
test('should backfill legacy permission id for new workspace invitation writes', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
}
|
||||
);
|
||||
|
||||
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const invitation = await db.workspaceInvitation.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: u1.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(invitation.legacyPermissionId, legacyRole.id);
|
||||
});
|
||||
|
||||
test('should get user workspace roles with filter', async t => {
|
||||
const ws1 = await module.create(Mockers.Workspace);
|
||||
const ws2 = await module.create(Mockers.Workspace);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { CryptoHelper, EventBus } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { LicenseService } from '../../plugins/license/service';
|
||||
import { PaymentEventHandlers } from '../../plugins/payment/event';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from '../../plugins/payment/types';
|
||||
|
||||
type Context = Record<string, never>;
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test('workspace subscription activation only sends upgrade notification', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
let reconciled = false;
|
||||
const handler = new PaymentEventHandlers(
|
||||
{
|
||||
isTeamWorkspace: async () => true,
|
||||
sendTeamWorkspaceUpgradedEmail: async () => {},
|
||||
} as unknown as WorkspaceService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => {
|
||||
reconciled = true;
|
||||
},
|
||||
} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 7 }),
|
||||
} as unknown as QuotaStateService,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await handler.onWorkspaceSubscriptionUpdated({
|
||||
workspaceId: 'ws',
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
quantity: 999,
|
||||
});
|
||||
|
||||
t.deepEqual(events, []);
|
||||
t.false(reconciled);
|
||||
});
|
||||
|
||||
test('workspace entitlement change allocates seats from effective quota state', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const handler = new PaymentEventHandlers(
|
||||
{} as unknown as WorkspaceService,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({
|
||||
plan: 'team',
|
||||
seatLimit: 7,
|
||||
}),
|
||||
} as unknown as QuotaStateService,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await handler.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: 'ws',
|
||||
});
|
||||
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.members.allocateSeats',
|
||||
payload: { workspaceId: 'ws', quantity: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('onetime selfhost license seat allocation ignores projected license quantity', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const service = new LicenseService(
|
||||
{
|
||||
installedLicense: {
|
||||
findUnique: async () => ({
|
||||
key: 'license-key',
|
||||
workspaceId: 'ws',
|
||||
quantity: 999,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as Models,
|
||||
{} as unknown as CryptoHelper,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{} as unknown as EntitlementService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 4 }),
|
||||
} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
await service.updateTeamSeats({
|
||||
workspaceId: 'ws',
|
||||
} as Events['workspace.members.updated']);
|
||||
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.members.allocateSeats',
|
||||
payload: { workspaceId: 'ws', quantity: 4 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const affineProRequests: string[] = [];
|
||||
const upserts: unknown[] = [];
|
||||
const entitlements: unknown[] = [];
|
||||
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
const service = new LicenseService(
|
||||
{
|
||||
installedLicense: {
|
||||
findUnique: async () => null,
|
||||
upsert: async (input: unknown) => {
|
||||
upserts.push(input);
|
||||
return {
|
||||
workspaceId: 'ws',
|
||||
key: 'license-key',
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as Models,
|
||||
{} as unknown as CryptoHelper,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
|
||||
entitlements.push(input);
|
||||
},
|
||||
} as unknown as EntitlementService,
|
||||
{} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
fetchAffinePro: (path: string) => Promise<{
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
endAt: number;
|
||||
res: Response;
|
||||
}>;
|
||||
}
|
||||
).fetchAffinePro = async (path: string) => {
|
||||
affineProRequests.push(path);
|
||||
return {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
endAt: expiresAt,
|
||||
res: new Response(null, {
|
||||
headers: {
|
||||
'x-next-validate-key': 'next-validate-key',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const license = await service.activateTeamLicense('ws', 'license-key');
|
||||
|
||||
t.like(license, {
|
||||
workspaceId: 'ws',
|
||||
key: 'license-key',
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
t.is(entitlements.length, 1);
|
||||
t.is(upserts.length, 1);
|
||||
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.subscription.activated',
|
||||
payload: {
|
||||
workspaceId: 'ws',
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -86,7 +86,10 @@ test('should cleanup expired pending blobs', async t => {
|
||||
],
|
||||
});
|
||||
|
||||
const abortSpy = Sinon.spy(t.context.storage, 'abortMultipartUpload');
|
||||
const abortSpy = Sinon.stub(
|
||||
t.context.storage,
|
||||
'abortMultipartUpload'
|
||||
).resolves();
|
||||
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
|
||||
t.teardown(() => {
|
||||
abortSpy.restore();
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TestingApp } from './utils';
|
||||
type TestContext = {
|
||||
app: TestingApp;
|
||||
};
|
||||
const test = ava as TestFn<TestContext>;
|
||||
const test = ava.serial as TestFn<TestContext>;
|
||||
|
||||
let safeFetchStub: Sinon.SinonStub | undefined;
|
||||
let safeFetchHandler:
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createHash } from 'node:crypto';
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Config, StorageProviderFactory } from '../../base';
|
||||
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
||||
import { BlobModel, WorkspaceFeatureModel } from '../../models';
|
||||
import {
|
||||
@@ -35,6 +36,18 @@ let model: WorkspaceFeatureModel;
|
||||
test.before(async () => {
|
||||
app = await createTestingApp();
|
||||
model = app.get(WorkspaceFeatureModel);
|
||||
app.get(ConfigFactory).override({
|
||||
storages: {
|
||||
blob: {
|
||||
storage: {
|
||||
provider: 'fs',
|
||||
bucket: 'test',
|
||||
config: { path: '/tmp/affine-test-storage' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.get(WorkspaceBlobStorage).onConfigInit();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
@@ -45,6 +58,26 @@ test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function withRestrictedWorkspaceQuota(workspaceId: string) {
|
||||
const quotaState = app.get(QuotaStateService);
|
||||
const blobModel = app.get(BlobModel);
|
||||
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
|
||||
async id => {
|
||||
if (id !== workspaceId) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
|
||||
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
|
||||
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test('should set blobs', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
@@ -233,7 +266,8 @@ test('should reject blob exceeded limit', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace1 = await createWorkspace(app);
|
||||
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
|
||||
const quotaStub = await withRestrictedWorkspaceQuota(workspace1.id);
|
||||
t.teardown(() => quotaStub.restore());
|
||||
|
||||
const buffer1 = Buffer.from(
|
||||
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
|
||||
@@ -247,7 +281,8 @@ test('should reject blob exceeded storage quota', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace = await createWorkspace(app);
|
||||
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
|
||||
const quotaStub = await withRestrictedWorkspaceQuota(workspace.id);
|
||||
t.teardown(() => quotaStub.restore());
|
||||
|
||||
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import Sinon from 'sinon';
|
||||
import supertest from 'supertest';
|
||||
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
|
||||
import { ConfigFactory } from '../../base';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { PermissionReadModel } from '../../core/permission/config';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
|
||||
import {
|
||||
@@ -152,6 +154,31 @@ test('should be able to get private workspace with public pages', async t => {
|
||||
t.is(res.text, 'blob');
|
||||
});
|
||||
|
||||
test('should be able to get private workspace with public pages using new permission model', async t => {
|
||||
const { app, storage } = t.context;
|
||||
const config = app.get(ConfigFactory);
|
||||
|
||||
config.override({
|
||||
permission: {
|
||||
readModel: PermissionReadModel.Projection,
|
||||
},
|
||||
});
|
||||
try {
|
||||
storage.get.resolves(blob());
|
||||
const res = await app.GET('/api/workspaces/private/blobs/test');
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
} finally {
|
||||
config.override({
|
||||
permission: {
|
||||
readModel: PermissionReadModel.Legacy,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should not be able to get private workspace with no public pages', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor';
|
||||
})
|
||||
export class CacheModule {}
|
||||
export { Cache, SessionCache };
|
||||
|
||||
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
|
||||
export { isValidCacheTtl } from './provider';
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface CacheSetOptions {
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export function isValidCacheTtl(ttl: unknown): ttl is number {
|
||||
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
Cache,
|
||||
CacheInterceptor,
|
||||
isValidCacheTtl,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
SessionCache,
|
||||
|
||||
@@ -62,6 +62,7 @@ export type KnownMetricScopes =
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process'
|
||||
| 'permission'
|
||||
| 'workspace';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden } from '../../base';
|
||||
import { ActionForbidden, EventBus } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { UserType } from '../user';
|
||||
@@ -26,7 +26,10 @@ class GenerateAccessTokenInput {
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Query(() => [RevealedAccessToken], {
|
||||
deprecationReason: 'use currentUser.revealedAccessTokens',
|
||||
@@ -42,11 +45,13 @@ export class AccessTokenResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
const token = await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
this.event.emit('user.access_token.created', { userId: user.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -55,6 +60,7 @@ export class AccessTokenResolver {
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
this.event.emit('user.access_token.revoked', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
|
||||
import { AccessController } from '../permission';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import {
|
||||
realtimeCommentRoom,
|
||||
RealtimePublisher,
|
||||
@@ -20,7 +20,7 @@ export function commentRoom(workspaceId: string, docId: string) {
|
||||
export class CommentRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly registry: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { Comment, DocMode, Models, Reply } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { ServerFeature, ServerService } from '../config';
|
||||
import { AccessController, DocAction } from '../permission';
|
||||
import { DocAction, PermissionAccess } from '../permission';
|
||||
import { RealtimePublisher } from '../realtime';
|
||||
import { CommentAttachmentStorage } from '../storage';
|
||||
import { UserType } from '../user';
|
||||
@@ -54,7 +54,7 @@ export interface CommentCursor {
|
||||
export class CommentResolver {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly models: Models,
|
||||
@@ -469,11 +469,7 @@ export class CommentResolver {
|
||||
|
||||
private async assertPermission(
|
||||
me: UserType,
|
||||
item: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId?: string;
|
||||
},
|
||||
item: { workspaceId: string; docId: string; userId?: string },
|
||||
action: DocAction
|
||||
) {
|
||||
// the owner of the comment/reply can update, delete, resolve it
|
||||
|
||||
@@ -173,7 +173,7 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
description: 'Workspace features available for admin configuration',
|
||||
})
|
||||
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
|
||||
return ['unlimited_workspace', 'team_plan_v1'];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Models } from '../../models';
|
||||
import { htmlSanitize } from '../../native';
|
||||
import { Public } from '../auth';
|
||||
import { DocReader } from '../doc';
|
||||
import { WorkspacePolicyService } from '../permission';
|
||||
import { PermissionService } from '../permission';
|
||||
|
||||
interface RenderOptions {
|
||||
title: string;
|
||||
@@ -61,7 +61,7 @@ export class DocRendererController {
|
||||
private readonly doc: DocReader,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
private readonly permission: PermissionService
|
||||
) {
|
||||
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
|
||||
this.mobileAssets = this.readHtmlAssets(
|
||||
@@ -99,10 +99,11 @@ export class DocRendererController {
|
||||
req.accepts().some(t => markdownType.has(t.toLowerCase()))
|
||||
) {
|
||||
try {
|
||||
const canReadMarkdown = await this.policy.canReadSharedDoc(
|
||||
const canReadMarkdown = await this.permission.canDoc({
|
||||
workspaceId,
|
||||
sub
|
||||
);
|
||||
docId: sub,
|
||||
action: 'Doc.Read',
|
||||
});
|
||||
if (!canReadMarkdown) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
@@ -162,7 +163,7 @@ export class DocRendererController {
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
if (await this.policy.canPreviewDoc(workspaceId, docId)) {
|
||||
if (await this.permission.canPreviewDoc({ workspaceId, docId })) {
|
||||
return this.doc.getDocContent(workspaceId, docId);
|
||||
}
|
||||
|
||||
@@ -172,8 +173,9 @@ export class DocRendererController {
|
||||
private async getWorkspaceContent(
|
||||
workspaceId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
const canPreviewWorkspace =
|
||||
await this.policy.canPreviewWorkspace(workspaceId);
|
||||
const canPreviewWorkspace = await this.permission.canPreviewWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
if (!canPreviewWorkspace) return null;
|
||||
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
@@ -73,7 +73,7 @@ export class DocStorageOptions implements IDocStorageOptions {
|
||||
|
||||
historyMaxAge = async (spaceId: string) => {
|
||||
const quota = await this.quota.getWorkspaceQuota(spaceId);
|
||||
return quota.historyPeriod;
|
||||
return quota.historyPeriod * 1000;
|
||||
};
|
||||
|
||||
historyMinInterval = (_spaceId: string) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import {
|
||||
EntitlementModule,
|
||||
EntitlementProjectionChecker,
|
||||
EntitlementService,
|
||||
} from '../index';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
entitlement: EntitlementService;
|
||||
checker: EntitlementProjectionChecker;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
t.context.checker = module.get(EntitlementProjectionChecker);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('checker distinguishes valid projection from dirty legacy features', async t => {
|
||||
const cleanUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: cleanUser.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const dirtyUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.userFeature.add(
|
||||
dirtyUser.id,
|
||||
'pro_plan_v1',
|
||||
'dirty legacy feature'
|
||||
);
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.dirtyLegacyUserFeatures, 1);
|
||||
t.is(report.missingUserFeatureProjection, 0);
|
||||
});
|
||||
|
||||
test('checker reports missing legacy projection and stale state', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.db.subscription.delete({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
});
|
||||
await t.context.db.effectiveUserQuotaState.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
staleAfter: new Date('2020-01-01T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.cloudSubscriptionProjectionMissing, 1);
|
||||
t.is(report.staleEffectiveUserState, 1);
|
||||
});
|
||||
|
||||
test('checker reports legal legacy facts missing entitlements', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'legacy-verifiable-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 5,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
validatedAt: new Date(),
|
||||
license: Buffer.from('raw-license'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.cloudSubscriptionEntitlementMissing, 1);
|
||||
t.is(report.selfhostLicenseEntitlementMissing, 1);
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { EntitlementModule, EntitlementService } from '../index';
|
||||
import { LegacyEntitlementProjectionService } from '../projection';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
entitlement: EntitlementService;
|
||||
projection: LegacyEntitlementProjectionService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
t.context.projection = module.get(LegacyEntitlementProjectionService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('projects user entitlement to legacy user features and subscriptions', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
|
||||
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
|
||||
t.like(
|
||||
await t.context.db.subscription.findUnique({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
}),
|
||||
{
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
}
|
||||
);
|
||||
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
});
|
||||
t.false(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
|
||||
});
|
||||
|
||||
test('projects workspace entitlement and readonly state to legacy workspace features', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
quantity: 8,
|
||||
});
|
||||
|
||||
const teamFeature = await t.context.models.workspaceFeature.get(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
);
|
||||
t.is(teamFeature?.configs.memberLimit, 8);
|
||||
|
||||
await t.context.db.effectiveWorkspaceQuotaState.upsert({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
create: {
|
||||
workspaceId: workspace.id,
|
||||
plan: 'free',
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 3,
|
||||
memberCount: 4,
|
||||
overcapacityMemberCount: 1,
|
||||
blobLimit: BigInt(10),
|
||||
storageQuota: BigInt(10),
|
||||
usedStorageQuota: BigInt(1),
|
||||
historyPeriodSeconds: 7,
|
||||
readonly: true,
|
||||
readonlyReasons: ['member_overflow'],
|
||||
known: true,
|
||||
stale: false,
|
||||
},
|
||||
update: {
|
||||
plan: 'free',
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 3,
|
||||
memberCount: 4,
|
||||
overcapacityMemberCount: 1,
|
||||
blobLimit: BigInt(10),
|
||||
storageQuota: BigInt(10),
|
||||
usedStorageQuota: BigInt(1),
|
||||
historyPeriodSeconds: 7,
|
||||
readonly: true,
|
||||
readonlyReasons: ['member_overflow'],
|
||||
known: true,
|
||||
stale: false,
|
||||
},
|
||||
});
|
||||
await t.context.projection.onWorkspaceQuotaStateChanged({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('installed license scanner never trusts quantity without raw license', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'legacy-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 100,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: '',
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.scanInstalledLicenses();
|
||||
|
||||
const entitlement = await t.context.db.entitlement.findFirst({
|
||||
where: {
|
||||
source: 'selfhost_license',
|
||||
subjectId: 'legacy-key',
|
||||
},
|
||||
});
|
||||
t.is(entitlement?.status, 'needs_reupload');
|
||||
t.is(entitlement?.quantity, null);
|
||||
});
|
||||
|
||||
test.serial(
|
||||
'selfhosted legacy projection ignores unknown entitlements',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'ai',
|
||||
status: 'active',
|
||||
subjectId: `forged-ai:${user.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
|
||||
t.false(
|
||||
await t.context.models.userFeature.has(user.id, 'unlimited_copilot')
|
||||
);
|
||||
t.is(
|
||||
await t.context.db.subscription.count({ where: { targetId: user.id } }),
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('backfill marks selfhost team subscriptions as needing license revalidation', async t => {
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: 'license-key-target',
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.backfillEntitlementsAndQuotaStates();
|
||||
|
||||
t.like(
|
||||
await t.context.db.entitlement.findFirstOrThrow({
|
||||
where: {
|
||||
source: 'selfhost_license',
|
||||
subjectId: 'license-key-target',
|
||||
},
|
||||
}),
|
||||
{
|
||||
targetType: 'instance',
|
||||
targetId: 'license-key-target',
|
||||
plan: 'selfhost_team',
|
||||
status: 'needs_reupload',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('backfill removes dangling legacy subscriptions and entitlements', async t => {
|
||||
await t.context.db.subscription.createMany({
|
||||
data: [
|
||||
{
|
||||
targetId: randomUUID(),
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
{
|
||||
targetId: randomUUID(),
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
await t.context.db.entitlement.createMany({
|
||||
data: [
|
||||
{
|
||||
targetType: 'user',
|
||||
targetId: randomUUID(),
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
subjectId: randomUUID(),
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: randomUUID(),
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
status: 'active',
|
||||
subjectId: randomUUID(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await t.context.projection.backfillEntitlementsAndQuotaStates();
|
||||
|
||||
t.is(await t.context.db.subscription.count(), 0);
|
||||
t.is(await t.context.db.entitlement.count(), 0);
|
||||
});
|
||||
|
||||
test('key based selfhost entitlements without raw payload need reupload', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: workspace.id,
|
||||
licenseKey: 'remote-key',
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
quantity: 5,
|
||||
validateKey: 'validate-key',
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
});
|
||||
|
||||
await t.context.projection.scanInstalledLicenses();
|
||||
|
||||
t.like(
|
||||
await t.context.db.entitlement.findFirstOrThrow({
|
||||
where: { source: 'selfhost_license', subjectId: 'remote-key' },
|
||||
}),
|
||||
{ status: 'needs_reupload', quantity: null }
|
||||
);
|
||||
});
|
||||
|
||||
test('revoked selfhost entitlement removes installed license projection', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'active',
|
||||
subjectId: 'revoked-key',
|
||||
quantity: 5,
|
||||
signedPayload: Buffer.from('signed-license-payload'),
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'revoked-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 5,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
validatedAt: new Date(),
|
||||
license: Buffer.from('signed-license-payload'),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.entitlement.revokeBySubject(
|
||||
'selfhost_license',
|
||||
'revoked-key'
|
||||
);
|
||||
|
||||
t.falsy(
|
||||
await t.context.db.installedLicense.findUnique({
|
||||
where: { workspaceId: workspace.id },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('installed license projection uses explicit entitlement status priority', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.entitlement.createMany({
|
||||
data: [
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'expired',
|
||||
subjectId: 'expired-key',
|
||||
quantity: 5,
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'expired-validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() - 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'grace',
|
||||
subjectId: 'grace-key',
|
||||
quantity: 6,
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'grace-validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() - 1800_000),
|
||||
graceUntil: new Date(Date.now() + 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
});
|
||||
|
||||
const installedLicense =
|
||||
await t.context.db.installedLicense.findUniqueOrThrow({
|
||||
where: { workspaceId: workspace.id },
|
||||
});
|
||||
t.is(installedLicense.key, 'grace-key');
|
||||
t.is(installedLicense.quantity, 6);
|
||||
t.is(installedLicense.validateKey, 'grace-validate-key');
|
||||
});
|
||||
|
||||
test.serial(
|
||||
'selfhosted projection does not trust non-null signed payload',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
status: 'active',
|
||||
subjectId: 'forged-key',
|
||||
quantity: 100,
|
||||
signedPayload: Buffer.from('not-a-valid-license'),
|
||||
metadata: {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
});
|
||||
|
||||
t.falsy(
|
||||
await t.context.models.workspaceFeature.get(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
)
|
||||
);
|
||||
t.falsy(
|
||||
await t.context.db.installedLicense.findUnique({
|
||||
where: { workspaceId: workspace.id },
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,508 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { EntitlementModule } from '../index';
|
||||
import { EntitlementService } from '../service';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
service: EntitlementService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.service = module.get(EntitlementService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('upserts admin grant entitlement as commercial source of truth', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'admin-grant-owner@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
const entitlement = await t.context.service.upsertAdminGrant({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
plan: 'team',
|
||||
quantity: 6,
|
||||
});
|
||||
const resolved = await t.context.service.resolveWorkspaceEntitlement(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(entitlement.source, 'admin_grant');
|
||||
t.is(entitlement.plan, 'team');
|
||||
t.is(entitlement.quantity, 6);
|
||||
t.is(resolved.plan, 'team');
|
||||
t.is(resolved.quota.seatLimit, 6);
|
||||
});
|
||||
|
||||
test('admin grant replaces and revokes previous admin grant', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'admin-grant-replace@affine.pro',
|
||||
});
|
||||
|
||||
await t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'lifetime_pro',
|
||||
});
|
||||
await t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
});
|
||||
|
||||
const [resolved, entitlements] = await Promise.all([
|
||||
t.context.service.resolveUserEntitlement(user.id),
|
||||
t.context.db.entitlement.findMany({
|
||||
where: { source: 'admin_grant', targetId: user.id },
|
||||
}),
|
||||
]);
|
||||
|
||||
t.is(resolved.plan, 'pro');
|
||||
t.is(
|
||||
entitlements.filter(entitlement => entitlement.status === 'active').length,
|
||||
1
|
||||
);
|
||||
t.false(
|
||||
entitlements.some(
|
||||
entitlement =>
|
||||
entitlement.plan === 'lifetime_pro' && entitlement.status === 'active'
|
||||
)
|
||||
);
|
||||
|
||||
await t.context.service.revokeAdminGrant('user', user.id);
|
||||
t.is((await t.context.service.resolveUserEntitlement(user.id)).plan, 'free');
|
||||
});
|
||||
|
||||
test('admin grant rejects self-hosted commercial entitlement without writing', async t => {
|
||||
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific entitlement semantics
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'admin-grant-selfhost@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
|
||||
try {
|
||||
await t.throwsAsync(
|
||||
t.context.service.upsertAdminGrant({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
plan: 'team',
|
||||
quantity: 6,
|
||||
}),
|
||||
{ message: /signed license/ }
|
||||
);
|
||||
t.is(
|
||||
await t.context.db.entitlement.count({
|
||||
where: { source: 'admin_grant', targetId: workspace.id },
|
||||
}),
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = originalDeploymentType;
|
||||
}
|
||||
});
|
||||
|
||||
test('admin grant rejects incompatible target plan without writing', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'admin-grant-invalid@affine.pro',
|
||||
});
|
||||
|
||||
await t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
});
|
||||
await t.throwsAsync(
|
||||
t.context.service.upsertAdminGrant({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: 'team',
|
||||
quantity: 6,
|
||||
}),
|
||||
{ message: /not configurable/ }
|
||||
);
|
||||
|
||||
const active = await t.context.db.entitlement.findMany({
|
||||
where: { source: 'admin_grant', targetId: user.id, status: 'active' },
|
||||
});
|
||||
t.is(active.length, 1);
|
||||
t.is(active[0].plan, 'pro');
|
||||
});
|
||||
|
||||
test('upserts cloud subscription entitlements without writing legacy features', async t => {
|
||||
const proUser = await t.context.models.user.create({
|
||||
email: 'user-pro@affine.pro',
|
||||
});
|
||||
const aiUser = await t.context.models.user.create({
|
||||
email: 'user-ai@affine.pro',
|
||||
});
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'workspace-owner@affine.pro',
|
||||
});
|
||||
const teamWorkspace = await t.context.models.workspace.create(owner.id);
|
||||
const cases = [
|
||||
{
|
||||
targetId: proUser.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
expected: { targetType: 'user', plan: 'pro', status: 'active' },
|
||||
},
|
||||
{
|
||||
targetId: aiUser.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'trialing',
|
||||
expected: { targetType: 'user', plan: 'ai', status: 'active' },
|
||||
},
|
||||
{
|
||||
targetId: teamWorkspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'past_due',
|
||||
quantity: 7,
|
||||
expected: { targetType: 'workspace', plan: 'team', status: 'grace' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
...item,
|
||||
subscriptionId: `${item.targetId}:${item.plan}`,
|
||||
start: new Date('2026-05-14T00:00:00Z'),
|
||||
});
|
||||
|
||||
t.like(entitlement, item.expected, item.targetId);
|
||||
}
|
||||
|
||||
t.is(await t.context.db.entitlement.count(), cases.length);
|
||||
});
|
||||
|
||||
test('revokes cloud subscription entitlement by subject', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'revoke-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
subscriptionId: 'sub_1',
|
||||
});
|
||||
|
||||
await t.context.service.revokeCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
subscriptionId: 'sub_1',
|
||||
});
|
||||
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
t.is(updated?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('revokes onetime or revenuecat entitlements using fallback subject', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'fallback-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await t.context.service.revokeCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
subscriptionId: 1,
|
||||
});
|
||||
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
t.is(updated?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('resolves higher priority commercial entitlement over ai capability', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'priority-user@affine.pro',
|
||||
});
|
||||
await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
t.is(resolved.plan, 'pro');
|
||||
t.is(resolved.quota.storageQuota, 100 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('ignores expired active entitlements during best entitlement selection', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'expired-user@affine.pro',
|
||||
});
|
||||
const cases = [
|
||||
{
|
||||
status: 'active',
|
||||
subjectId: 'expired-subscription',
|
||||
expiresAt: new Date('2020-01-01T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
status: 'grace',
|
||||
subjectId: 'open-ended-grace',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
...item,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
t.is(resolved.plan, 'free');
|
||||
});
|
||||
|
||||
test('selfhosted resolution ignores unsigned DB entitlements', async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test mutates env singleton for deployment-specific trust boundary
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
try {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'forged-user@affine.pro',
|
||||
});
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'forged-workspace-owner@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
const cases = [
|
||||
{
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'ai',
|
||||
quantity: null,
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
quantity: 100,
|
||||
},
|
||||
{
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
source: 'selfhost_license',
|
||||
plan: 'selfhost_team',
|
||||
quantity: 100,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const item of cases) {
|
||||
await t.context.db.entitlement.create({
|
||||
data: {
|
||||
...item,
|
||||
status: 'active',
|
||||
subjectId: `${item.source}:${item.plan}:${item.targetId}`,
|
||||
quantity: item.quantity ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
|
||||
t.falsy(
|
||||
await t.context.service.getBestEntitlement('workspace', workspace.id)
|
||||
);
|
||||
|
||||
const userResolved = await t.context.service.resolveUserEntitlement(
|
||||
user.id
|
||||
);
|
||||
const workspaceResolved =
|
||||
await t.context.service.resolveWorkspaceEntitlement(workspace.id);
|
||||
|
||||
t.is(userResolved.plan, 'selfhost_free');
|
||||
t.is(workspaceResolved.plan, 'selfhost_free');
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
});
|
||||
|
||||
test('cloud resolution lazily imports legacy subscriptions written after backfill', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'legacy-subscription-user@affine.pro',
|
||||
});
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity: 1,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const userResolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
const userEntitlement = await t.context.db.entitlement.findFirst({
|
||||
where: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(userResolved.plan, 'pro');
|
||||
t.is(userEntitlement?.status, 'active');
|
||||
|
||||
const owner = await t.context.models.user.create({
|
||||
email: 'legacy-subscription-owner@affine.pro',
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity: 7,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const workspaceResolved = await t.context.service.resolveWorkspaceEntitlement(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.is(workspaceResolved.plan, 'team');
|
||||
t.is(workspaceResolved.quantity, 7);
|
||||
t.is(workspaceResolved.quota.seatLimit, 7);
|
||||
|
||||
await t.context.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
|
||||
const revokedResolved = await t.context.service.resolveUserEntitlement(
|
||||
user.id
|
||||
);
|
||||
const revokedEntitlement = await t.context.db.entitlement.findFirst({
|
||||
where: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(revokedResolved.plan, 'free');
|
||||
t.is(revokedEntitlement?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('cloud resolution revokes projected entitlements after legacy subscription deletion', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'legacy-delete-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
|
||||
await t.context.db.subscription.findUniqueOrThrow({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
await t.context.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
|
||||
t.is(resolved.plan, 'free');
|
||||
t.is(updated?.status, 'revoked');
|
||||
});
|
||||
|
||||
test('cloud resolution keeps projected string-subscription entitlements while legacy row exists', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'string-subscription-user@affine.pro',
|
||||
});
|
||||
const entitlement = await t.context.service.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
subscriptionId: 'sub_legacy_string',
|
||||
});
|
||||
|
||||
await t.context.db.subscription.findUniqueOrThrow({
|
||||
where: {
|
||||
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await t.context.service.resolveUserEntitlement(user.id);
|
||||
const updated = await t.context.db.entitlement.findUnique({
|
||||
where: { id: entitlement.id },
|
||||
});
|
||||
|
||||
t.is(resolved.plan, 'pro');
|
||||
t.is(updated?.status, 'active');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LegacyEntitlementProjectionService } from './projection';
|
||||
import { EntitlementProjectionChecker } from './projection-checker';
|
||||
import { EntitlementService } from './service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
EntitlementService,
|
||||
LegacyEntitlementProjectionService,
|
||||
EntitlementProjectionChecker,
|
||||
],
|
||||
exports: [
|
||||
EntitlementService,
|
||||
LegacyEntitlementProjectionService,
|
||||
EntitlementProjectionChecker,
|
||||
],
|
||||
})
|
||||
export class EntitlementModule {}
|
||||
|
||||
export { EntitlementService };
|
||||
export { EntitlementProjectionChecker };
|
||||
export { LegacyEntitlementProjectionService };
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class EntitlementProjectionChecker {
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
|
||||
async checkEntitlementProjection() {
|
||||
const now = new Date();
|
||||
const [
|
||||
missingEffectiveUserState,
|
||||
missingEffectiveWorkspaceState,
|
||||
staleEffectiveUserState,
|
||||
staleEffectiveWorkspaceState,
|
||||
cloudSubscriptionProjectionMissing,
|
||||
selfhostLicenseProjectionMissing,
|
||||
cloudSubscriptionEntitlementMissing,
|
||||
selfhostLicenseEntitlementMissing,
|
||||
dirtyLegacyUserFeatures,
|
||||
dirtyLegacyWorkspaceFeatures,
|
||||
missingUserFeatureProjection,
|
||||
missingWorkspaceFeatureProjection,
|
||||
] = await Promise.all([
|
||||
this.db.user.count({
|
||||
where: { quotaState: null },
|
||||
}),
|
||||
this.db.workspace.count({
|
||||
where: { quotaState: null },
|
||||
}),
|
||||
this.db.effectiveUserQuotaState.count({
|
||||
where: {
|
||||
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
|
||||
},
|
||||
}),
|
||||
this.db.effectiveWorkspaceQuotaState.count({
|
||||
where: {
|
||||
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
|
||||
},
|
||||
}),
|
||||
this.cloudSubscriptionProjectionMissing(),
|
||||
this.selfhostLicenseProjectionMissing(),
|
||||
this.cloudSubscriptionEntitlementMissing(),
|
||||
this.selfhostLicenseEntitlementMissing(),
|
||||
this.dirtyLegacyUserFeatures(),
|
||||
this.dirtyLegacyWorkspaceFeatures(),
|
||||
this.missingUserFeatureProjection(),
|
||||
this.missingWorkspaceFeatureProjection(),
|
||||
]);
|
||||
|
||||
return {
|
||||
missingEffectiveUserState,
|
||||
missingEffectiveWorkspaceState,
|
||||
staleEffectiveUserState,
|
||||
staleEffectiveWorkspaceState,
|
||||
cloudSubscriptionProjectionMissing,
|
||||
selfhostLicenseProjectionMissing,
|
||||
cloudSubscriptionEntitlementMissing,
|
||||
selfhostLicenseEntitlementMissing,
|
||||
dirtyLegacyUserFeatures,
|
||||
dirtyLegacyWorkspaceFeatures,
|
||||
missingUserFeatureProjection,
|
||||
missingWorkspaceFeatureProjection,
|
||||
};
|
||||
}
|
||||
|
||||
private async cloudSubscriptionProjectionMissing() {
|
||||
const legacyKeys = new Set(
|
||||
(
|
||||
await this.db.subscription.findMany({
|
||||
where: {
|
||||
status: { in: ['active', 'trialing', 'past_due'] },
|
||||
},
|
||||
select: { targetId: true, plan: true },
|
||||
})
|
||||
).map(subscription => `${subscription.targetId}:${subscription.plan}`)
|
||||
);
|
||||
const entitlements = await this.validEntitlements({
|
||||
source: 'cloud_subscription',
|
||||
});
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.targetId &&
|
||||
!legacyKeys.has(
|
||||
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
|
||||
)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async selfhostLicenseProjectionMissing() {
|
||||
const licenseKeys = new Set(
|
||||
(
|
||||
await this.db.installedLicense.findMany({
|
||||
select: { key: true },
|
||||
})
|
||||
).map(license => license.key)
|
||||
);
|
||||
const entitlements = await this.validEntitlements({
|
||||
source: 'selfhost_license',
|
||||
});
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.subjectId && !licenseKeys.has(entitlement.subjectId)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async cloudSubscriptionEntitlementMissing() {
|
||||
const activeSubscriptions = await this.db.subscription.findMany({
|
||||
where: {
|
||||
status: { in: ['active', 'trialing', 'past_due'] },
|
||||
},
|
||||
select: { targetId: true, plan: true },
|
||||
});
|
||||
const valid = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
source: 'cloud_subscription',
|
||||
})
|
||||
).map(
|
||||
entitlement =>
|
||||
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
|
||||
)
|
||||
);
|
||||
|
||||
return activeSubscriptions.filter(
|
||||
subscription =>
|
||||
!valid.has(`${subscription.targetId}:${subscription.plan}`)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async selfhostLicenseEntitlementMissing() {
|
||||
const licenses = await this.db.installedLicense.findMany({
|
||||
where: {
|
||||
license: { not: null },
|
||||
},
|
||||
select: { key: true },
|
||||
});
|
||||
const validKeys = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
source: 'selfhost_license',
|
||||
})
|
||||
).flatMap(entitlement => entitlement.subjectId ?? [])
|
||||
);
|
||||
|
||||
return licenses.filter(license => !validKeys.has(license.key)).length;
|
||||
}
|
||||
|
||||
private async dirtyLegacyUserFeatures() {
|
||||
const rows = await this.db.userFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: {
|
||||
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const valid = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
targetType: 'user',
|
||||
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
|
||||
})
|
||||
).map(entitlement => `${entitlement.targetId}:${entitlement.plan}`)
|
||||
);
|
||||
|
||||
return rows.filter(row => {
|
||||
const plan =
|
||||
row.name === 'lifetime_pro_plan_v1'
|
||||
? 'lifetime_pro'
|
||||
: row.name === 'pro_plan_v1'
|
||||
? 'pro'
|
||||
: 'ai';
|
||||
return !valid.has(`${row.userId}:${plan}`);
|
||||
}).length;
|
||||
}
|
||||
|
||||
private async dirtyLegacyWorkspaceFeatures() {
|
||||
const rows = await this.db.workspaceFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: 'team_plan_v1',
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
const validWorkspaceIds = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
targetType: 'workspace',
|
||||
plan: { in: ['team', 'selfhost_team'] },
|
||||
})
|
||||
).flatMap(entitlement => entitlement.targetId ?? [])
|
||||
);
|
||||
|
||||
return rows.filter(row => !validWorkspaceIds.has(row.workspaceId)).length;
|
||||
}
|
||||
|
||||
private async missingUserFeatureProjection() {
|
||||
const entitlements = await this.validEntitlements({
|
||||
targetType: 'user',
|
||||
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
|
||||
});
|
||||
const features = new Set(
|
||||
(
|
||||
await this.db.userFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: {
|
||||
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
|
||||
},
|
||||
},
|
||||
select: { userId: true, name: true },
|
||||
})
|
||||
).map(feature => `${feature.userId}:${feature.name}`)
|
||||
);
|
||||
|
||||
return entitlements.filter(entitlement => {
|
||||
if (!entitlement.targetId) {
|
||||
return false;
|
||||
}
|
||||
const feature =
|
||||
entitlement.plan === 'lifetime_pro'
|
||||
? 'lifetime_pro_plan_v1'
|
||||
: entitlement.plan === 'pro'
|
||||
? 'pro_plan_v1'
|
||||
: 'unlimited_copilot';
|
||||
return !features.has(`${entitlement.targetId}:${feature}`);
|
||||
}).length;
|
||||
}
|
||||
|
||||
private async missingWorkspaceFeatureProjection() {
|
||||
const entitlements = await this.validEntitlements({
|
||||
targetType: 'workspace',
|
||||
plan: { in: ['team', 'selfhost_team'] },
|
||||
});
|
||||
const featureWorkspaceIds = new Set(
|
||||
(
|
||||
await this.db.workspaceFeature.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
name: 'team_plan_v1',
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
})
|
||||
).map(feature => feature.workspaceId)
|
||||
);
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.targetId && !featureWorkspaceIds.has(entitlement.targetId)
|
||||
).length;
|
||||
}
|
||||
|
||||
private validEntitlements(where: Record<string, unknown>) {
|
||||
const now = new Date();
|
||||
return this.db.entitlement.findMany({
|
||||
where: {
|
||||
...where,
|
||||
...(where.source === 'selfhost_license'
|
||||
? { signedPayload: { not: null } }
|
||||
: {}),
|
||||
OR: [
|
||||
{
|
||||
status: 'active',
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
{
|
||||
status: 'grace',
|
||||
graceUntil: { gt: now },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
targetId: true,
|
||||
subjectId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private subscriptionPlan(plan: string) {
|
||||
return plan === 'lifetime_pro' ? 'pro' : plan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Entitlement, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../plugins/payment/types';
|
||||
import { EntitlementService } from './service';
|
||||
|
||||
type Metadata = {
|
||||
provider?: string | null;
|
||||
recurring?: string | null;
|
||||
variant?: string | null;
|
||||
subscriptionId?: string | number | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
validateKey?: string | null;
|
||||
legacyProjected?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LegacyEntitlementProjectionService {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {}
|
||||
|
||||
@OnEvent('entitlement.changed')
|
||||
async onEntitlementChanged({
|
||||
targetType,
|
||||
targetId,
|
||||
}: Events['entitlement.changed']) {
|
||||
if (targetType === 'user') {
|
||||
await this.#projectCloudSubscriptions('user', targetId);
|
||||
await this.#projectUserFeatures(targetId);
|
||||
} else if (targetType === 'workspace') {
|
||||
await this.#projectCloudSubscriptions('workspace', targetId);
|
||||
await Promise.all([
|
||||
this.#projectWorkspaceFeatures(targetId),
|
||||
this.#projectInstalledLicense(targetId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('workspace.quota_state.changed')
|
||||
async onWorkspaceQuotaStateChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.quota_state.changed']) {
|
||||
await this.#projectReadonlyFeature(workspaceId);
|
||||
}
|
||||
|
||||
async scanInstalledLicenses() {
|
||||
const licenses = await this.db.installedLicense.findMany();
|
||||
|
||||
await Promise.all(
|
||||
licenses.map(async license =>
|
||||
license.license
|
||||
? await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
license: Buffer.from(license.license),
|
||||
})
|
||||
: license.validateKey
|
||||
? await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
validateKey: license.validateKey,
|
||||
variant: license.variant,
|
||||
})
|
||||
: await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
reason: 'Installed license has no raw payload to verify.',
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async backfillEntitlementsAndQuotaStates() {
|
||||
await this.#cleanupDanglingLegacyEntitlements();
|
||||
|
||||
const [subscriptions, users, workspaces] = await Promise.all([
|
||||
this.db.subscription.findMany(),
|
||||
this.db.user.findMany({ select: { id: true } }),
|
||||
this.db.workspace.findMany({ select: { id: true } }),
|
||||
]);
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
if (!(await this.#subscriptionTargetExists(subscription))) {
|
||||
continue;
|
||||
}
|
||||
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
|
||||
await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||
licenseKey: subscription.targetId,
|
||||
reason:
|
||||
'Historical self-hosted team subscription needs license activation or revalidation.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
}
|
||||
|
||||
await this.scanInstalledLicenses();
|
||||
|
||||
await Promise.all([
|
||||
...users.map(user =>
|
||||
this.db.effectiveUserQuotaState.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { stale: true },
|
||||
create: {
|
||||
userId: user.id,
|
||||
plan: 'free',
|
||||
blobLimit: BigInt(0),
|
||||
storageQuota: BigInt(0),
|
||||
usedStorageQuota: BigInt(0),
|
||||
historyPeriodSeconds: 0,
|
||||
known: false,
|
||||
stale: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
...workspaces.map(workspace =>
|
||||
this.db.effectiveWorkspaceQuotaState.upsert({
|
||||
where: { workspaceId: workspace.id },
|
||||
update: { stale: true },
|
||||
create: {
|
||||
workspaceId: workspace.id,
|
||||
plan: 'free',
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 0,
|
||||
memberCount: 0,
|
||||
overcapacityMemberCount: 0,
|
||||
blobLimit: BigInt(0),
|
||||
storageQuota: BigInt(0),
|
||||
usedStorageQuota: BigInt(0),
|
||||
historyPeriodSeconds: 0,
|
||||
known: false,
|
||||
stale: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async #cleanupDanglingLegacyEntitlements() {
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM entitlements entitlement
|
||||
WHERE (
|
||||
entitlement.target_type = 'user'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = entitlement.target_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
entitlement.target_type = 'workspace'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM workspaces
|
||||
WHERE workspaces.id = entitlement.target_id
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM subscriptions subscription
|
||||
WHERE (
|
||||
subscription.plan IN (${SubscriptionPlan.Pro}, ${SubscriptionPlan.AI})
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = subscription.target_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
subscription.plan = ${SubscriptionPlan.Team}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM workspaces
|
||||
WHERE workspaces.id = subscription.target_id
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
async #subscriptionTargetExists(subscription: {
|
||||
targetId: string;
|
||||
plan: string;
|
||||
}) {
|
||||
if (
|
||||
subscription.plan === SubscriptionPlan.Pro ||
|
||||
subscription.plan === SubscriptionPlan.AI
|
||||
) {
|
||||
return !!(await this.db.user.findUnique({
|
||||
where: { id: subscription.targetId },
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
if (subscription.plan === SubscriptionPlan.Team) {
|
||||
return !!(await this.db.workspace.findUnique({
|
||||
where: { id: subscription.targetId },
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #projectUserFeatures(userId: string) {
|
||||
const entitlements = await this.#activeEntitlements('user', userId);
|
||||
const quotaEntitlement = entitlements.find(entitlement =>
|
||||
['lifetime_pro', 'pro'].includes(entitlement.plan)
|
||||
);
|
||||
|
||||
if (quotaEntitlement?.plan === 'lifetime_pro') {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'lifetime_pro_plan_v1',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
} else if (quotaEntitlement?.plan === 'pro') {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'pro_plan_v1',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
} else if (
|
||||
await this.hasActiveUserFeature(userId, [
|
||||
'pro_plan_v1',
|
||||
'lifetime_pro_plan_v1',
|
||||
])
|
||||
) {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'free_plan_v1',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
}
|
||||
|
||||
if (entitlements.some(entitlement => entitlement.plan === 'ai')) {
|
||||
await this.models.userFeature.add(
|
||||
userId,
|
||||
'unlimited_copilot',
|
||||
'legacy entitlement projection'
|
||||
);
|
||||
} else {
|
||||
await this.models.userFeature.remove(userId, 'unlimited_copilot');
|
||||
}
|
||||
}
|
||||
|
||||
async #projectWorkspaceFeatures(workspaceId: string) {
|
||||
const [entitlement, resolved] = await Promise.all([
|
||||
this.entitlement.getBestEntitlement('workspace', workspaceId),
|
||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||
]);
|
||||
|
||||
if (
|
||||
entitlement &&
|
||||
['team', 'selfhost_team'].includes(resolved.plan) &&
|
||||
resolved.valid &&
|
||||
resolved.quota.seatLimit
|
||||
) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'team_plan_v1',
|
||||
'legacy entitlement projection',
|
||||
{
|
||||
memberLimit: resolved.quota.seatLimit,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
}
|
||||
}
|
||||
|
||||
async #projectCloudSubscriptions(
|
||||
targetType: 'user' | 'workspace',
|
||||
targetId: string
|
||||
) {
|
||||
if (env.selfhosted) return;
|
||||
const entitlements = await this.db.entitlement.findMany({
|
||||
where: {
|
||||
targetType,
|
||||
targetId,
|
||||
source: 'cloud_subscription',
|
||||
},
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const entitlement of this.#projectableCloudEntitlements(
|
||||
entitlements
|
||||
)) {
|
||||
const metadata = entitlement.metadata as Metadata;
|
||||
await this.db.subscription.upsert({
|
||||
where: {
|
||||
targetId_plan: {
|
||||
targetId,
|
||||
plan: this.#subscriptionPlan(entitlement.plan),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
||||
provider: this.#provider(metadata.provider),
|
||||
status: this.#subscriptionStatus(entitlement.status),
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialEnd: entitlement.graceUntil,
|
||||
},
|
||||
create: {
|
||||
targetId,
|
||||
plan: this.#subscriptionPlan(entitlement.plan),
|
||||
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
||||
provider: this.#provider(metadata.provider),
|
||||
status: this.#subscriptionStatus(entitlement.status),
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialEnd: entitlement.graceUntil,
|
||||
},
|
||||
});
|
||||
if (!metadata.legacyProjected) {
|
||||
await this.db.entitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
data: {
|
||||
metadata: {
|
||||
...metadata,
|
||||
legacyProjected: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*#projectableCloudEntitlements(entitlements: Entitlement[]) {
|
||||
const byPlan = new Map<string, Entitlement>();
|
||||
|
||||
for (const entitlement of entitlements) {
|
||||
const plan = this.#subscriptionPlan(entitlement.plan);
|
||||
const current = byPlan.get(plan);
|
||||
|
||||
if (
|
||||
!current ||
|
||||
this.#subscriptionProjectionPriority(entitlement) >
|
||||
this.#subscriptionProjectionPriority(current)
|
||||
) {
|
||||
byPlan.set(plan, entitlement);
|
||||
}
|
||||
}
|
||||
|
||||
yield* byPlan.values();
|
||||
}
|
||||
|
||||
#subscriptionProjectionPriority(entitlement: {
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
const statusPriority =
|
||||
entitlement.status === 'active' || entitlement.status === 'grace'
|
||||
? 2
|
||||
: entitlement.status === 'expired'
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
return (
|
||||
statusPriority * 10_000_000_000_000 + entitlement.updatedAt.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async #projectInstalledLicense(workspaceId: string) {
|
||||
const [entitlements, resolved] = await Promise.all([
|
||||
this.db.entitlement.findMany({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
source: 'selfhost_license',
|
||||
},
|
||||
orderBy: [{ signedPayload: 'desc' }, { updatedAt: 'desc' }],
|
||||
}),
|
||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||
]);
|
||||
const entitlement = entitlements.sort(
|
||||
(left, right) =>
|
||||
this.#installedLicenseStatusPriority(right.status) -
|
||||
this.#installedLicenseStatusPriority(left.status) ||
|
||||
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
|
||||
right.updatedAt.getTime() - left.updatedAt.getTime()
|
||||
)[0];
|
||||
|
||||
if (!entitlement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolved.plan !== 'selfhost_team' ||
|
||||
!['active', 'grace', 'expired'].includes(resolved.status)
|
||||
) {
|
||||
await this.db.installedLicense.deleteMany({
|
||||
where: { workspaceId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = entitlement.metadata as Metadata;
|
||||
const expiredAt = resolved.expiresAt
|
||||
? new Date(resolved.expiresAt)
|
||||
: entitlement.expiresAt;
|
||||
await this.db.installedLicense.upsert({
|
||||
where: { workspaceId },
|
||||
update: {
|
||||
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
|
||||
quantity: resolved.quantity ?? 1,
|
||||
recurring:
|
||||
resolved.recurring ??
|
||||
metadata.recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
validateKey: metadata.validateKey ?? '',
|
||||
validatedAt: entitlement.validatedAt ?? new Date(),
|
||||
expiredAt,
|
||||
license: entitlement.signedPayload
|
||||
? Buffer.from(entitlement.signedPayload)
|
||||
: null,
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
|
||||
quantity: resolved.quantity ?? 1,
|
||||
recurring:
|
||||
resolved.recurring ??
|
||||
metadata.recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
validateKey: metadata.validateKey ?? '',
|
||||
validatedAt: entitlement.validatedAt ?? new Date(),
|
||||
expiredAt,
|
||||
license: entitlement.signedPayload
|
||||
? Buffer.from(entitlement.signedPayload)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#installedLicenseStatusPriority(status: string) {
|
||||
if (status === 'active' || status === 'grace') {
|
||||
return 3;
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return 2;
|
||||
}
|
||||
if (status === 'needs_reupload') {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async #projectReadonlyFeature(workspaceId: string) {
|
||||
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (state?.readonly) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'quota_exceeded_readonly_workspace_v1',
|
||||
`legacy quota state projection: ${state.readonlyReasons.join(',')}`
|
||||
);
|
||||
} else {
|
||||
await this.models.workspaceFeature.remove(
|
||||
workspaceId,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #activeEntitlements(
|
||||
targetType: 'user' | 'workspace',
|
||||
targetId: string
|
||||
) {
|
||||
return this.entitlement.getActiveEntitlements(targetType, targetId);
|
||||
}
|
||||
|
||||
private async hasActiveUserFeature(userId: string, names: string[]) {
|
||||
const count = await this.db.userFeature.count({
|
||||
where: {
|
||||
userId,
|
||||
name: { in: names },
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
#subscriptionPlan(plan: string) {
|
||||
if (plan === 'lifetime_pro') {
|
||||
return SubscriptionPlan.Pro;
|
||||
}
|
||||
if (plan === 'selfhost_team') {
|
||||
return SubscriptionPlan.SelfHostedTeam;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
#subscriptionStatus(status: string) {
|
||||
if (status === 'active') {
|
||||
return SubscriptionStatus.Active;
|
||||
}
|
||||
if (status === 'grace') {
|
||||
return SubscriptionStatus.PastDue;
|
||||
}
|
||||
return SubscriptionStatus.Canceled;
|
||||
}
|
||||
|
||||
#provider(provider: string | null | undefined) {
|
||||
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { EntitlementModule } from '../entitlement';
|
||||
import {
|
||||
AdminFeatureManagementResolver,
|
||||
UserFeatureResolver,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
import { EarlyAccessType, FeatureService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [EntitlementModule],
|
||||
providers: [
|
||||
UserFeatureResolver,
|
||||
AdminFeatureManagementResolver,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
registerEnumType,
|
||||
@@ -8,13 +9,10 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
type UserFeatureName,
|
||||
type WorkspaceFeatureName,
|
||||
} from '../../models';
|
||||
import { BadRequest, EventBus } from '../../base';
|
||||
import { Feature, Models, type UserFeatureName } from '../../models';
|
||||
import { Admin } from '../common';
|
||||
import { EntitlementService } from '../entitlement';
|
||||
import { UserType } from '../user/types';
|
||||
import { AvailableUserFeatureConfig } from './types';
|
||||
|
||||
@@ -42,7 +40,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
|
||||
@Admin()
|
||||
@Resolver(() => Boolean)
|
||||
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
constructor(private readonly models: Models) {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly event: EventBus
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -55,44 +57,58 @@ export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
features: UserFeatureName[]
|
||||
) {
|
||||
const configurableUserFeatures = this.configurableUserFeatures();
|
||||
const unsupported = features.filter(
|
||||
feature => !configurableUserFeatures.has(feature)
|
||||
);
|
||||
if (unsupported.length) {
|
||||
throw new BadRequest(
|
||||
`User feature ${unsupported.join(', ')} is not configurable`
|
||||
);
|
||||
}
|
||||
const removed = difference(Array.from(configurableUserFeatures), features);
|
||||
|
||||
await Promise.all(
|
||||
features.map(async feature => {
|
||||
if (configurableUserFeatures.has(feature)) {
|
||||
return this.models.userFeature.add(id, feature, 'admin panel');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
})
|
||||
features.map(feature =>
|
||||
this.models.userFeature.add(id, feature, 'admin panel')
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
removed.map(feature => this.models.userFeature.remove(id, feature))
|
||||
);
|
||||
|
||||
const user = await this.models.user.get(id);
|
||||
if (user) {
|
||||
this.event.emit('user.updated', user);
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async addWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
|
||||
async grantCommercialEntitlement(
|
||||
@Args('targetType', { type: () => String })
|
||||
targetType: 'user' | 'workspace',
|
||||
@Args('targetId', { type: () => String }) targetId: string,
|
||||
@Args('plan', { type: () => String }) plan: string,
|
||||
@Args('quantity', { type: () => Int, nullable: true }) quantity?: number
|
||||
) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
feature,
|
||||
'by administrator'
|
||||
);
|
||||
await this.entitlement.upsertAdminGrant({
|
||||
targetType,
|
||||
targetId,
|
||||
plan,
|
||||
quantity,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async removeWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
|
||||
async revokeCommercialEntitlement(
|
||||
@Args('targetType', { type: () => String })
|
||||
targetType: 'user' | 'workspace',
|
||||
@Args('targetId', { type: () => String }) targetId: string
|
||||
) {
|
||||
await this.models.workspaceFeature.remove(workspaceId, feature);
|
||||
await this.entitlement.revokeAdminGrant(targetType, targetId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,14 @@ import { Feature, UserFeatureName } from '../../models';
|
||||
@Injectable()
|
||||
export class AvailableUserFeatureConfig {
|
||||
availableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set([
|
||||
Feature.Admin,
|
||||
Feature.UnlimitedCopilot,
|
||||
Feature.EarlyAccess,
|
||||
Feature.AIEarlyAccess,
|
||||
]);
|
||||
return new Set([Feature.Admin, Feature.EarlyAccess, Feature.AIEarlyAccess]);
|
||||
}
|
||||
|
||||
configurableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set(
|
||||
env.selfhosted
|
||||
? [Feature.Admin, Feature.UnlimitedCopilot]
|
||||
: [
|
||||
Feature.EarlyAccess,
|
||||
Feature.AIEarlyAccess,
|
||||
Feature.Admin,
|
||||
Feature.UnlimitedCopilot,
|
||||
]
|
||||
? [Feature.Admin]
|
||||
: [Feature.EarlyAccess, Feature.AIEarlyAccess, Feature.Admin]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { paginate, PaginationInput } from '../../base/graphql';
|
||||
import { MentionNotificationCreateSchema } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { AccessController } from '../permission';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import { UserType } from '../user';
|
||||
import { NotificationService } from './service';
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
export class UserNotificationResolver {
|
||||
constructor(
|
||||
private readonly service: NotificationService,
|
||||
private readonly ac: AccessController
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => PaginatedNotificationObjectType, {
|
||||
|
||||
@@ -229,6 +229,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': false,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': false,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': false,
|
||||
@@ -251,6 +252,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': false,
|
||||
@@ -273,6 +275,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -295,6 +298,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Comments.Update': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -317,6 +321,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Comments.Update': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -339,6 +344,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Comments.Update': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -361,6 +367,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Comments.Update': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@@ -412,6 +419,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
'Doc.Comments.Delete': 'Editor',
|
||||
'Doc.Comments.Read': 'External',
|
||||
'Doc.Comments.Resolve': 'Editor',
|
||||
'Doc.Comments.Update': 'Editor',
|
||||
'Doc.Copy': 'External',
|
||||
'Doc.Delete': 'Editor',
|
||||
'Doc.Duplicate': 'Reader',
|
||||
|
||||
BIN
Binary file not shown.
@@ -10,14 +10,13 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { DocAccessController } from '../doc';
|
||||
import { PermissionModule } from '../index';
|
||||
import { PermissionAccess, PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
import { DocRole, mapDocRoleToPermissions } from '../types';
|
||||
|
||||
let module: TestingModule;
|
||||
let models: Models;
|
||||
let ac: DocAccessController;
|
||||
let ac: PermissionAccess;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
@@ -26,7 +25,7 @@ let underReviewUserId: string;
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = module.get(DocAccessController);
|
||||
ac = module.get(PermissionAccess);
|
||||
policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
@@ -40,6 +39,21 @@ test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
function doc(resource: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const checker = ac
|
||||
.user(resource.userId)
|
||||
.doc(resource.workspaceId, resource.docId);
|
||||
if (resource.allowLocal) {
|
||||
checker.allowLocal();
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
const roleCases: Array<{
|
||||
title: string;
|
||||
setup?: () => Promise<void>;
|
||||
@@ -90,7 +104,7 @@ const roleCases: Array<{
|
||||
expectedRole: DocRole.Owner,
|
||||
},
|
||||
{
|
||||
title: 'should fallback to [External] if workspace is public',
|
||||
title: 'should not grant private doc role if workspace is public',
|
||||
setup: async () => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
@@ -101,7 +115,7 @@ const roleCases: Array<{
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: DocRole.External,
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return null even if workspace has other public doc',
|
||||
@@ -131,9 +145,13 @@ const roleCases: Array<{
|
||||
title: 'should return null if doc role is [None]',
|
||||
setup: async () => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
const u2 = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
underReviewUserId = u2.id;
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
user.id,
|
||||
underReviewUserId,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
@@ -143,7 +161,7 @@ const roleCases: Array<{
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
userId: underReviewUserId,
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
@@ -151,14 +169,6 @@ const roleCases: Array<{
|
||||
title: 'should return [External] if doc role is [None] but doc is public',
|
||||
setup: async () => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
user.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
},
|
||||
resource: () => ({
|
||||
@@ -174,18 +184,18 @@ for (const roleCase of roleCases) {
|
||||
test(roleCase.title, async t => {
|
||||
await roleCase.setup?.();
|
||||
const resource = roleCase.resource();
|
||||
const role = await ac.getRole(resource);
|
||||
const role = (await doc(resource).permissions()).role;
|
||||
|
||||
t.is(role, roleCase.expectedRole);
|
||||
});
|
||||
}
|
||||
|
||||
test('should return mapped permissions', async t => {
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
|
||||
});
|
||||
@@ -195,11 +205,11 @@ test('should deny publish permission when workspace sharing is disabled', async
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.false(permissions['Doc.Publish']);
|
||||
t.true(permissions['Doc.Read']);
|
||||
@@ -211,24 +221,18 @@ test('should deny publish assert when workspace sharing is disabled', async t =>
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Publish'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}).assert('Doc.Publish')
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Read'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}).assert('Doc.Read')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,34 +243,27 @@ test('should deny external read assert when sharing is disabled even if doc is p
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Doc.Read'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}).assert('Doc.Read')
|
||||
);
|
||||
});
|
||||
|
||||
test('should assert action', async t => {
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Update'
|
||||
)
|
||||
doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}).assert('Doc.Update')
|
||||
);
|
||||
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
|
||||
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
|
||||
'Doc.Update'
|
||||
)
|
||||
);
|
||||
@@ -278,8 +275,7 @@ test('should assert action', async t => {
|
||||
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
|
||||
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
|
||||
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
|
||||
'Doc.Delete'
|
||||
)
|
||||
);
|
||||
@@ -301,11 +297,11 @@ test('should apply readonly doc restrictions while keeping cleanup actions', asy
|
||||
}
|
||||
await policy.reconcileWorkspaceQuotaState(ws.id);
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await doc({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.false(permissions['Doc.Update']);
|
||||
t.false(permissions['Doc.Publish']);
|
||||
|
||||
@@ -1,20 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { Models } from '../../../models';
|
||||
import { AccessControllerBuilder } from '../builder';
|
||||
import { PermissionDiagnosticService } from '../diagnostic';
|
||||
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
|
||||
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
|
||||
import type { DocAction } from '../types';
|
||||
|
||||
const module = await createModule({
|
||||
imports: [PermissionModule],
|
||||
});
|
||||
|
||||
const builder = module.get(AccessControllerBuilder);
|
||||
const models = module.get(Models);
|
||||
const db = module.get(PrismaClient);
|
||||
const diagnostic = module.get(PermissionDiagnosticService);
|
||||
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
async function sqlReadableDocIds(input: {
|
||||
workspaceId: string;
|
||||
userId?: string;
|
||||
action?: DocAction;
|
||||
docIds: string[];
|
||||
}) {
|
||||
const values = Prisma.join(
|
||||
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
|
||||
);
|
||||
const predicate = sqlPredicate.docReadableByNewTablesSql({
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
action: input.action ?? 'Doc.Read',
|
||||
docIdColumn: Prisma.raw('c.doc_id'),
|
||||
});
|
||||
const rows = await db.$queryRaw<{ docId: string }[]>`
|
||||
WITH candidates(doc_id, ord) AS (VALUES ${values})
|
||||
SELECT c.doc_id AS "docId"
|
||||
FROM candidates c
|
||||
WHERE ${predicate}
|
||||
ORDER BY c.ord ASC
|
||||
`;
|
||||
return rows.map(row => row.docId);
|
||||
}
|
||||
|
||||
async function resetProjection(workspaceId: string) {
|
||||
await db.$executeRaw`DELETE FROM doc_grants WHERE workspace_id = ${workspaceId}`;
|
||||
await db.$executeRaw`DELETE FROM doc_access_policies WHERE workspace_id = ${workspaceId}`;
|
||||
await db.$executeRaw`DELETE FROM workspace_members WHERE workspace_id = ${workspaceId}`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_access_policies (
|
||||
workspace_id,
|
||||
visibility,
|
||||
sharing_enabled,
|
||||
url_preview_enabled,
|
||||
member_default_doc_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspaceId}, 'private', true, false, 'none', now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
visibility = EXCLUDED.visibility,
|
||||
sharing_enabled = EXCLUDED.sharing_enabled,
|
||||
url_preview_enabled = EXCLUDED.url_preview_enabled,
|
||||
member_default_doc_role = EXCLUDED.member_default_doc_role,
|
||||
updated_at = now()
|
||||
`;
|
||||
await models.workspaceRuntimeState.upsert(workspaceId, {
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
});
|
||||
}
|
||||
|
||||
test('should filter docs by Doc.Read', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
@@ -79,11 +143,329 @@ test('should filter docs by Doc.Read', async t => {
|
||||
t.is(docs3.length, 0);
|
||||
});
|
||||
|
||||
test('SQL doc read predicate matches Rust for projection default and public candidates', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await resetProjection(workspace.id);
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_access_policies
|
||||
SET member_default_doc_role = 'reader'
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_members (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
state,
|
||||
source,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_access_policies (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
visibility,
|
||||
public_role,
|
||||
member_default_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, 'member-default-none', 'private', NULL, 'none', now()),
|
||||
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now())
|
||||
`;
|
||||
|
||||
const docIds = ['missing-policy', 'member-default-none', 'public-doc'];
|
||||
const sqlReadable = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
docIds,
|
||||
});
|
||||
const shadow = await diagnostic.shadowSqlDocRead({
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
docs: docIds.map(docId => ({ docId })),
|
||||
sqlReadableDocIds: sqlReadable,
|
||||
});
|
||||
|
||||
t.deepEqual(sqlReadable, ['missing-policy', 'public-doc']);
|
||||
t.true(shadow.matched);
|
||||
});
|
||||
|
||||
test('SQL doc read predicate matches Rust for non-member grant and sharing disabled', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const nonMember = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await resetProjection(workspace.id);
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_access_policies (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
visibility,
|
||||
public_role,
|
||||
member_default_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now()),
|
||||
(${workspace.id}, 'private-doc', 'private', NULL, NULL, now()),
|
||||
(${workspace.id}, 'explicit-grant', 'private', NULL, NULL, now()),
|
||||
(${workspace.id}, 'explicit-owner-grant', 'private', NULL, NULL, now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_grants (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
principal_type,
|
||||
principal_id,
|
||||
role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
${workspace.id},
|
||||
'explicit-grant',
|
||||
'user',
|
||||
${nonMember.id},
|
||||
'reader',
|
||||
now()
|
||||
),
|
||||
(
|
||||
${workspace.id},
|
||||
'explicit-owner-grant',
|
||||
'user',
|
||||
${nonMember.id},
|
||||
'owner',
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
const docIds = [
|
||||
'public-doc',
|
||||
'private-doc',
|
||||
'explicit-grant',
|
||||
'explicit-owner-grant',
|
||||
];
|
||||
const sharingEnabledReadable = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docIds,
|
||||
});
|
||||
const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docs: docIds.map(docId => ({ docId })),
|
||||
sqlReadableDocIds: sharingEnabledReadable,
|
||||
});
|
||||
const sharingEnabledUpdate = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
action: 'Doc.Update',
|
||||
docIds,
|
||||
});
|
||||
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_access_policies
|
||||
SET sharing_enabled = false
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
`;
|
||||
const sharingDisabledReadable = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docIds,
|
||||
});
|
||||
const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({
|
||||
workspaceId: workspace.id,
|
||||
userId: nonMember.id,
|
||||
docs: docIds.map(docId => ({ docId })),
|
||||
sqlReadableDocIds: sharingDisabledReadable,
|
||||
});
|
||||
|
||||
t.deepEqual(sharingEnabledReadable, [
|
||||
'public-doc',
|
||||
'explicit-grant',
|
||||
'explicit-owner-grant',
|
||||
]);
|
||||
t.true(sharingEnabledShadow.matched);
|
||||
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
|
||||
t.deepEqual(sharingDisabledReadable, []);
|
||||
t.true(sharingDisabledShadow.matched);
|
||||
});
|
||||
|
||||
test('SQL doc predicate suppresses member default when explicit grant exists', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await resetProjection(workspace.id);
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_access_policies
|
||||
SET member_default_doc_role = 'manager'
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_members (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
state,
|
||||
source,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_access_policies (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
visibility,
|
||||
public_role,
|
||||
member_default_role,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, 'default-manager', 'private', NULL, NULL, now()),
|
||||
(${workspace.id}, 'explicit-reader', 'private', NULL, NULL, now())
|
||||
`;
|
||||
await db.$executeRaw`
|
||||
INSERT INTO doc_grants (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
principal_type,
|
||||
principal_id,
|
||||
role,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
'explicit-reader',
|
||||
'user',
|
||||
${member.id},
|
||||
'reader',
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
const docIds = ['default-manager', 'explicit-reader'];
|
||||
const sqlUpdateAllowed = await sqlReadableDocIds({
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
action: 'Doc.Update',
|
||||
docIds,
|
||||
});
|
||||
|
||||
t.deepEqual(sqlUpdateAllowed, ['default-manager']);
|
||||
});
|
||||
|
||||
test('legacy SQL doc predicate matches external row and explicit grant cap semantics', async t => {
|
||||
const workspaceId = randomUUID();
|
||||
const memberId = randomUUID();
|
||||
const externalId = randomUUID();
|
||||
|
||||
async function fixtureLegacyDocIds(input: {
|
||||
userId: string;
|
||||
action: DocAction;
|
||||
docIds: string[];
|
||||
}) {
|
||||
const values = Prisma.join(
|
||||
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
|
||||
);
|
||||
const predicate = sqlPredicate.docReadableByLegacyTablesSql({
|
||||
workspaceId,
|
||||
userId: input.userId,
|
||||
action: input.action,
|
||||
docIdColumn: Prisma.raw('c.doc_id'),
|
||||
});
|
||||
// Current triggers reject newly inserted legacy External workspace rows;
|
||||
// CTEs let the same predicate run in Postgres against historical shapes.
|
||||
const rows = await db.$queryRaw<{ docId: string }[]>`
|
||||
WITH
|
||||
workspaces(id, enable_sharing) AS (
|
||||
VALUES (${workspaceId}, true)
|
||||
),
|
||||
workspace_pages(workspace_id, page_id, public, "defaultRole") AS (
|
||||
VALUES
|
||||
(${workspaceId}, 'default-manager', false, ${DocRole.Manager}::smallint),
|
||||
(${workspaceId}, 'explicit-reader', false, ${DocRole.Manager}::smallint),
|
||||
(${workspaceId}, 'external-owner', false, ${DocRole.Manager}::smallint),
|
||||
(${workspaceId}, 'dirty-external', false, ${DocRole.Manager}::smallint)
|
||||
),
|
||||
workspace_user_permissions(
|
||||
id,
|
||||
workspace_id,
|
||||
user_id,
|
||||
status,
|
||||
type
|
||||
) AS (
|
||||
VALUES
|
||||
(${randomUUID()}, ${workspaceId}, ${memberId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.Collaborator}::smallint),
|
||||
(${randomUUID()}, ${workspaceId}, ${externalId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.External}::smallint)
|
||||
),
|
||||
workspace_page_user_permissions(
|
||||
workspace_id,
|
||||
page_id,
|
||||
user_id,
|
||||
type
|
||||
) AS (
|
||||
VALUES
|
||||
(${workspaceId}, 'explicit-reader', ${memberId}, ${DocRole.Reader}::smallint),
|
||||
(${workspaceId}, 'external-owner', ${externalId}, ${DocRole.Owner}::smallint),
|
||||
(${workspaceId}, 'dirty-external', ${externalId}, ${DocRole.External}::smallint)
|
||||
),
|
||||
candidates(doc_id, ord) AS (VALUES ${values})
|
||||
SELECT c.doc_id AS "docId"
|
||||
FROM candidates c
|
||||
WHERE ${predicate}
|
||||
ORDER BY c.ord ASC
|
||||
`;
|
||||
return rows.map(row => row.docId);
|
||||
}
|
||||
|
||||
const memberUpdateAllowed = await fixtureLegacyDocIds({
|
||||
userId: memberId,
|
||||
action: 'Doc.Update',
|
||||
docIds: ['default-manager', 'explicit-reader'],
|
||||
});
|
||||
const externalUpdateAllowed = await fixtureLegacyDocIds({
|
||||
userId: externalId,
|
||||
action: 'Doc.Update',
|
||||
docIds: ['external-owner', 'dirty-external'],
|
||||
});
|
||||
const externalManageAllowed = await fixtureLegacyDocIds({
|
||||
userId: externalId,
|
||||
action: 'Doc.Users.Manage',
|
||||
docIds: ['external-owner', 'dirty-external'],
|
||||
});
|
||||
const externalTransferAllowed = await fixtureLegacyDocIds({
|
||||
userId: externalId,
|
||||
action: 'Doc.TransferOwner',
|
||||
docIds: ['external-owner', 'dirty-external'],
|
||||
});
|
||||
|
||||
t.deepEqual(memberUpdateAllowed, ['default-manager']);
|
||||
t.deepEqual(externalUpdateAllowed, ['external-owner']);
|
||||
t.deepEqual(externalManageAllowed, []);
|
||||
t.deepEqual(externalTransferAllowed, []);
|
||||
});
|
||||
|
||||
test('should filter docs by Doc.Publish', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
await models.workspace.update(workspace.id, { enableSharing: true });
|
||||
await models.workspaceRuntimeState.upsert(workspace.id, {
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
});
|
||||
|
||||
const docs1 = await builder
|
||||
.user(owner.id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
@@ -7,11 +8,6 @@ import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import {
|
||||
DocActionDenied,
|
||||
OwnerCanNotLeaveWorkspace,
|
||||
SpaceAccessDenied,
|
||||
} from '../../../base';
|
||||
import {
|
||||
Models,
|
||||
User,
|
||||
@@ -19,25 +15,59 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { QuotaService } from '../../quota/service';
|
||||
import { QuotaServiceModule } from '../../quota/service.module';
|
||||
import { QuotaStateService } from '../../quota/state';
|
||||
import { PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
policy: WorkspacePolicyService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
const READONLY_FEATURE = 'quota_exceeded_readonly_workspace_v1' as const;
|
||||
type WorkspaceQuotaSnapshot = Awaited<
|
||||
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
|
||||
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
|
||||
> & {
|
||||
ownerQuota?: string;
|
||||
readonlyReasons: string[];
|
||||
};
|
||||
|
||||
const readonlyWorkspaceState = (
|
||||
workspaceId: string,
|
||||
readonlyReasons: string[],
|
||||
overrides: Partial<WorkspaceQuotaSnapshot> = {}
|
||||
) =>
|
||||
({
|
||||
workspaceId,
|
||||
plan: 'free',
|
||||
sourceEntitlementId: null,
|
||||
ownerUserId: owner.id,
|
||||
usesOwnerQuota: true,
|
||||
seatLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: readonlyReasons.includes('member_overflow')
|
||||
? 1
|
||||
: 0,
|
||||
blobLimit: BigInt(1),
|
||||
storageQuota: BigInt(1),
|
||||
usedStorageQuota: readonlyReasons.includes('storage_overflow')
|
||||
? BigInt(2)
|
||||
: BigInt(0),
|
||||
historyPeriodSeconds: 1,
|
||||
readonly: readonlyReasons.length > 0,
|
||||
readonlyReasons,
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: new Date(),
|
||||
staleAfter: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) satisfies WorkspaceQuotaSnapshot;
|
||||
async function addAcceptedMembers(
|
||||
models: Models,
|
||||
workspaceId: string,
|
||||
@@ -64,6 +94,7 @@ let workspace: Workspace;
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [PermissionModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
@@ -81,21 +112,23 @@ test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should reuse quota service exported by quota service module', async t => {
|
||||
test('should reuse quota state service exported by quota service module', async t => {
|
||||
const module = await createTestingModule(
|
||||
{ imports: [PermissionModule, QuotaServiceModule] },
|
||||
false
|
||||
);
|
||||
|
||||
try {
|
||||
const quota = module.select(QuotaServiceModule).get(QuotaService, {
|
||||
strict: true,
|
||||
});
|
||||
const quotaState = module
|
||||
.select(QuotaServiceModule)
|
||||
.get(QuotaStateService, {
|
||||
strict: true,
|
||||
});
|
||||
const policy = module.select(PermissionModule).get(WorkspacePolicyService, {
|
||||
strict: true,
|
||||
});
|
||||
|
||||
t.is(Reflect.get(policy, 'quota'), quota);
|
||||
t.is(Reflect.get(policy, 'quotaState'), quotaState);
|
||||
} finally {
|
||||
await module.close();
|
||||
}
|
||||
@@ -108,12 +141,9 @@ test('should keep owned workspace writable when quota is within limit', async t
|
||||
|
||||
t.false(state.isReadonly);
|
||||
t.deepEqual(state.readonlyReasons, []);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner member quota overflows', async t => {
|
||||
test('should report readonly state when fallback owner member quota overflows', async t => {
|
||||
await addAcceptedMembers(t.context.models, workspace.id, 10);
|
||||
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
@@ -124,91 +154,16 @@ test('should enter readonly mode when fallback owner member quota overflows', as
|
||||
t.true(state.canRecoverByRemovingMembers);
|
||||
t.false(state.canRecoverByDeletingBlobs);
|
||||
t.deepEqual(state.readonlyReasons, ['member_overflow']);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
await t.throwsAsync(t.context.policy.assertCanInviteMembers(workspace.id), {
|
||||
instanceOf: SpaceAccessDenied,
|
||||
});
|
||||
});
|
||||
|
||||
test('should deny blob uploads when user no longer has write access', async t => {
|
||||
const external = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
external.id,
|
||||
WorkspaceRole.External,
|
||||
{ status: WorkspaceMemberStatus.Accepted }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanUploadBlob(external.id, workspace.id),
|
||||
{ instanceOf: SpaceAccessDenied }
|
||||
);
|
||||
});
|
||||
|
||||
test('should deny publish through policy when workspace sharing is disabled', async t => {
|
||||
await t.context.models.workspace.update(workspace.id, {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanPublishDoc(owner.id, workspace.id, 'doc1'),
|
||||
{ instanceOf: DocActionDenied }
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanUnpublishDoc(owner.id, workspace.id, 'doc1')
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow managers to revoke invite links in readonly workspace', async t => {
|
||||
await addAcceptedMembers(t.context.models, workspace.id, 10);
|
||||
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
|
||||
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanManageInviteLink(owner.id, workspace.id)
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply leave workspace policy by role', async t => {
|
||||
const collaborator = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{ status: WorkspaceMemberStatus.Accepted }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanLeaveWorkspace(owner.id, workspace.id),
|
||||
{ instanceOf: OwnerCanNotLeaveWorkspace }
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanLeaveWorkspace(collaborator.id, workspace.id)
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner storage quota overflows', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
'getWorkspaceQuotaWithUsage'
|
||||
const quotaState = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
|
||||
'reconcileWorkspaceQuotaState'
|
||||
);
|
||||
quotaState.callsFake(async workspaceId =>
|
||||
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
|
||||
);
|
||||
quota.resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 2,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 2,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
@@ -218,57 +173,26 @@ test('should enter readonly mode when fallback owner storage quota overflows', a
|
||||
t.false(state.canRecoverByRemovingMembers);
|
||||
t.true(state.canRecoverByDeletingBlobs);
|
||||
t.deepEqual(state.readonlyReasons, ['storage_overflow']);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave readonly mode after workspace usage recovers', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
'getWorkspaceQuotaWithUsage'
|
||||
test('should report recovered state after workspace usage recovers', async t => {
|
||||
const quotaState = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
|
||||
'reconcileWorkspaceQuotaState'
|
||||
);
|
||||
quota.onFirstCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 2,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 2,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quota.onSecondCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 0,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quota.onThirdCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 0,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quotaState
|
||||
.onFirstCall()
|
||||
.callsFake(async workspaceId =>
|
||||
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
|
||||
);
|
||||
quotaState
|
||||
.onSecondCall()
|
||||
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
|
||||
quotaState
|
||||
.onThirdCall()
|
||||
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
|
||||
|
||||
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
|
||||
const recovered = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
@@ -276,10 +200,6 @@ test('should leave readonly mode after workspace usage recovers', async t => {
|
||||
|
||||
t.false(recovered.isReadonly);
|
||||
t.deepEqual(recovered.readonlyReasons, []);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
await t.notThrowsAsync(t.context.policy.assertCanInviteMembers(workspace.id));
|
||||
});
|
||||
|
||||
test('should roll back team cancellation cleanup when cleanup fails', async t => {
|
||||
@@ -289,11 +209,58 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
|
||||
const admin = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
pending.id,
|
||||
WorkspaceRole.Collaborator
|
||||
);
|
||||
await t.context.db.$transaction(async db => {
|
||||
await db.$executeRaw`
|
||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
||||
`;
|
||||
const pendingPermission = await db.workspaceUserRole.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId: pending.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
},
|
||||
});
|
||||
const [invitationShape] = await db.$queryRaw<Array<{ current: boolean }>>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_invitations'
|
||||
AND column_name = 'requested_role'
|
||||
) AS "current"
|
||||
`;
|
||||
if (invitationShape?.current) {
|
||||
await db.workspaceInvitation.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: pending.id,
|
||||
requestedRole: 'member',
|
||||
status: 'pending',
|
||||
kind: 'email',
|
||||
legacyPermissionId: pendingPermission.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_invitations (
|
||||
workspace_id,
|
||||
invitee_user_id,
|
||||
role,
|
||||
state,
|
||||
source,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${pending.id},
|
||||
${'member'},
|
||||
${'pending'},
|
||||
${'email'},
|
||||
now()
|
||||
)
|
||||
`;
|
||||
}
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
admin.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,13 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { PermissionModule } from '../index';
|
||||
import { PermissionAccess, PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
import { mapWorkspaceRoleToPermissions } from '../types';
|
||||
import { WorkspaceAccessController } from '../workspace';
|
||||
|
||||
let module: TestingModule;
|
||||
let models: Models;
|
||||
let ac: WorkspaceAccessController;
|
||||
let ac: PermissionAccess;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
@@ -26,7 +25,7 @@ let underReviewUserId: string;
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = module.get(WorkspaceAccessController);
|
||||
ac = module.get(PermissionAccess);
|
||||
policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
@@ -138,10 +137,34 @@ const roleCases: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
async function getRole(resource: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
|
||||
if (resource.allowLocal) {
|
||||
checker.allowLocal();
|
||||
}
|
||||
return (await checker.permissions()).role;
|
||||
}
|
||||
|
||||
function workspace(resource: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
|
||||
if (resource.allowLocal) {
|
||||
checker.allowLocal();
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
for (const roleCase of roleCases) {
|
||||
test(roleCase.title, async t => {
|
||||
await roleCase.setup?.();
|
||||
const role = await ac.getRole(roleCase.resource());
|
||||
const role = await getRole(roleCase.resource());
|
||||
|
||||
t.is(role, roleCase.expectedRole);
|
||||
});
|
||||
@@ -150,10 +173,10 @@ for (const roleCase of roleCases) {
|
||||
test('should return mapped null permission even workspace has public docs', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(null));
|
||||
});
|
||||
@@ -162,13 +185,10 @@ test('should deny external read assert even workspace has public docs', async t
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Workspace.Read'
|
||||
)
|
||||
workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}).assert('Workspace.Read')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -177,13 +197,10 @@ test('should deny external read assert when sharing disabled even if workspace h
|
||||
await models.workspace.update(ws.id, { enableSharing: false });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Workspace.Read'
|
||||
)
|
||||
workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}).assert('Workspace.Read')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -193,31 +210,27 @@ test('should reject external doc roles when sharing disabled', async t => {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const [docRole] = await ac.docRoles(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
['doc1']
|
||||
);
|
||||
const docRole = await ac
|
||||
.user('random-user-id')
|
||||
.doc(ws.id, 'doc1')
|
||||
.permissions();
|
||||
|
||||
t.is(docRole.role, null);
|
||||
t.false(docRole.permissions['Doc.Read']);
|
||||
});
|
||||
|
||||
test('should return mapped permissions', async t => {
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner));
|
||||
});
|
||||
|
||||
test('should assert action', async t => {
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, userId: user.id },
|
||||
workspace({ workspaceId: ws.id, userId: user.id }).assert(
|
||||
'Workspace.TransferOwner'
|
||||
)
|
||||
);
|
||||
@@ -225,7 +238,7 @@ test('should assert action', async t => {
|
||||
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
|
||||
workspace({ workspaceId: ws.id, userId: u2.id }).assert('Workspace.Sync')
|
||||
);
|
||||
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Admin, {
|
||||
@@ -233,8 +246,7 @@ test('should assert action', async t => {
|
||||
});
|
||||
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{ workspaceId: ws.id, userId: u2.id },
|
||||
workspace({ workspaceId: ws.id, userId: u2.id }).assert(
|
||||
'Workspace.Settings.Update'
|
||||
)
|
||||
);
|
||||
@@ -256,10 +268,10 @@ test('should apply readonly workspace restrictions while keeping cleanup actions
|
||||
}
|
||||
await policy.reconcileWorkspaceQuotaState(ws.id);
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
const { permissions } = await workspace({
|
||||
workspaceId: ws.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}).permissions();
|
||||
|
||||
t.false(permissions['Workspace.CreateDoc']);
|
||||
t.false(permissions['Workspace.Settings.Update']);
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DocID } from '../utils/doc';
|
||||
import { getAccessController } from './controller';
|
||||
import { Resource } from './resource';
|
||||
import { DocAction, WorkspaceAction } from './types';
|
||||
import { WorkspaceAccessController } from './workspace';
|
||||
import { PermissionService } from './service';
|
||||
import {
|
||||
DOC_ACTIONS,
|
||||
DocAction,
|
||||
DocRole,
|
||||
WORKSPACE_ACTIONS,
|
||||
WorkspaceAction,
|
||||
WorkspaceRole,
|
||||
} from './types';
|
||||
|
||||
function assertPerm(permission?: PermissionService) {
|
||||
if (!permission) {
|
||||
throw new Error('PermissionService is required for permission checks.');
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessControllerBuilder {
|
||||
constructor(private readonly permission?: PermissionService) {}
|
||||
|
||||
user(userId: string) {
|
||||
return new UserAccessControllerBuilder(userId);
|
||||
return new UserAccessControllerBuilder(userId, this.permission);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccessControllerBuilder {
|
||||
constructor(private readonly userId: string) {}
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
workspace(workspaceId: string) {
|
||||
return new WorkspaceAccessControllerBuilder({
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
});
|
||||
return new WorkspaceAccessControllerBuilder(
|
||||
{
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
},
|
||||
this.permission
|
||||
);
|
||||
}
|
||||
|
||||
doc(
|
||||
@@ -45,16 +66,22 @@ export class UserAccessControllerBuilder {
|
||||
docId = docIdOrWorkspaceId.docId;
|
||||
}
|
||||
|
||||
return new DocAccessControllerBuilder({
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
docId,
|
||||
});
|
||||
return new DocAccessControllerBuilder(
|
||||
{
|
||||
userId: this.userId,
|
||||
workspaceId,
|
||||
docId,
|
||||
},
|
||||
this.permission
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkspaceAccessControllerBuilder {
|
||||
constructor(public readonly data: Resource<'ws'>) {}
|
||||
constructor(
|
||||
public readonly data: Resource<'ws'>,
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
allowLocal() {
|
||||
this.data.allowLocal = true;
|
||||
@@ -62,10 +89,13 @@ class WorkspaceAccessControllerBuilder {
|
||||
}
|
||||
|
||||
doc(docId: string) {
|
||||
return new DocAccessControllerBuilder({
|
||||
...this.data,
|
||||
docId,
|
||||
});
|
||||
return new DocAccessControllerBuilder(
|
||||
{
|
||||
...this.data,
|
||||
docId,
|
||||
},
|
||||
this.permission
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,35 +109,61 @@ class WorkspaceAccessControllerBuilder {
|
||||
action: DocAction
|
||||
): Promise<T[]> {
|
||||
const docIds = items.map(item => item.docId);
|
||||
const checker = getAccessController('ws') as WorkspaceAccessController;
|
||||
const docRoles = await checker.docRoles(this.data, docIds);
|
||||
const docRoles = await assertPerm(this.permission).batchDocPermissions({
|
||||
userId: this.data.userId,
|
||||
workspaceId: this.data.workspaceId,
|
||||
docs: docIds.map(docId => ({
|
||||
docId,
|
||||
actions: [action],
|
||||
})),
|
||||
allowLocal: this.data.allowLocal,
|
||||
});
|
||||
const docRolesMap = new Map(
|
||||
docRoles.map((role, index) => [docIds[index], role])
|
||||
);
|
||||
|
||||
return items.filter(item => {
|
||||
return docRolesMap.get(item.docId)?.permissions[action];
|
||||
return docRolesMap
|
||||
.get(item.docId)
|
||||
?.decisions.some(
|
||||
decision => decision.action === action && decision.allowed
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async assert(action: WorkspaceAction) {
|
||||
const checker = getAccessController('ws');
|
||||
await checker.assert(this.data, action);
|
||||
await assertPerm(this.permission).assertWorkspace({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async can(action: WorkspaceAction) {
|
||||
const checker = getAccessController('ws');
|
||||
return await checker.can(this.data, action);
|
||||
return await assertPerm(this.permission).canWorkspace({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async permissions() {
|
||||
const checker = getAccessController('ws');
|
||||
return await checker.role(this.data);
|
||||
const result = await assertPerm(this.permission).workspacePermissions({
|
||||
...this.data,
|
||||
actions: [...WORKSPACE_ACTIONS],
|
||||
});
|
||||
return {
|
||||
role: result.legacyApiRole as WorkspaceRole | null,
|
||||
permissions: Object.fromEntries(
|
||||
result.decisions.map(decision => [decision.action, decision.allowed])
|
||||
) as Record<WorkspaceAction, boolean>,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocAccessControllerBuilder {
|
||||
constructor(public readonly data: Resource<'doc'>) {}
|
||||
constructor(
|
||||
public readonly data: Resource<'doc'>,
|
||||
private readonly permission?: PermissionService
|
||||
) {}
|
||||
|
||||
allowLocal() {
|
||||
this.data.allowLocal = true;
|
||||
@@ -115,17 +171,29 @@ class DocAccessControllerBuilder {
|
||||
}
|
||||
|
||||
async assert(action: DocAction) {
|
||||
const checker = getAccessController('doc');
|
||||
await checker.assert(this.data, action);
|
||||
await assertPerm(this.permission).assertDoc({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async can(action: DocAction) {
|
||||
const checker = getAccessController('doc');
|
||||
return await checker.can(this.data, action);
|
||||
return await assertPerm(this.permission).canDoc({
|
||||
...this.data,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async permissions() {
|
||||
const checker = getAccessController('doc');
|
||||
return await checker.role(this.data);
|
||||
const result = await assertPerm(this.permission).docPermissions({
|
||||
...this.data,
|
||||
actions: [...DOC_ACTIONS],
|
||||
});
|
||||
return {
|
||||
role: result.legacyApiRole as DocRole | null,
|
||||
permissions: Object.fromEntries(
|
||||
result.decisions.map(decision => [decision.action, decision.allowed])
|
||||
) as Record<DocAction, boolean>,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
export enum PermissionReadModel {
|
||||
Legacy = 'legacy',
|
||||
Projection = 'projection',
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
permission: {
|
||||
readModel: PermissionReadModel;
|
||||
fallbackLegacyLoader: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('permission', {
|
||||
readModel: {
|
||||
desc: 'Permission data source for Rust evaluation',
|
||||
default: PermissionReadModel.Projection,
|
||||
shape: z.nativeEnum(PermissionReadModel),
|
||||
env: ['AFFINE_PERMISSION_READ_MODEL', 'string'],
|
||||
},
|
||||
fallbackLegacyLoader: {
|
||||
desc: 'Fallback from projection loader to legacy loader when projection input loading fails',
|
||||
default: false,
|
||||
env: ['AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER', 'boolean'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
import { DocRole, Models } from '../../models';
|
||||
import type { PermissionEvaluationInputV1 } from '../../native';
|
||||
import {
|
||||
toNativeDocRole,
|
||||
toNativeExplicitDocGrantRole,
|
||||
toNativeMemberState,
|
||||
toNativeWorkspaceRole,
|
||||
} from './context';
|
||||
import type { DocAction, WorkspaceAction } from './types';
|
||||
|
||||
type PermissionRequestCache = {
|
||||
workspaceMember: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['workspaceUser']['get']>>
|
||||
>;
|
||||
workspacePolicy: Map<string, Awaited<ReturnType<Models['workspace']['get']>>>;
|
||||
workspaceRuntime: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['workspaceRuntimeState']['get']>>
|
||||
>;
|
||||
workspaceQuotaRuntime: Map<string, NewWorkspaceRuntimeState>;
|
||||
docPolicies: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['doc']['findDefaultRoles']>>
|
||||
>;
|
||||
docGrants: Map<string, Awaited<ReturnType<Models['docUser']['findMany']>>>;
|
||||
};
|
||||
|
||||
type NewWorkspaceMemberRow = {
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
state: 'active' | 'suspended' | 'left';
|
||||
};
|
||||
|
||||
type NewWorkspacePolicyRow = {
|
||||
visibility: 'private' | 'public';
|
||||
sharingEnabled: boolean;
|
||||
urlPreviewEnabled: boolean;
|
||||
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
|
||||
};
|
||||
|
||||
type NewDocPolicyRow = {
|
||||
docId: string;
|
||||
visibility: 'private' | 'public';
|
||||
publicRole: 'external' | null;
|
||||
memberDefaultRole:
|
||||
| 'none'
|
||||
| 'reader'
|
||||
| 'commenter'
|
||||
| 'editor'
|
||||
| 'manager'
|
||||
| null;
|
||||
urlPreviewEnabled: boolean;
|
||||
};
|
||||
|
||||
type NewDocGrantRow = {
|
||||
docId: string;
|
||||
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
|
||||
};
|
||||
|
||||
type NewWorkspaceRuntimeState = {
|
||||
known: boolean;
|
||||
stale: boolean;
|
||||
readonly: boolean;
|
||||
readonlyReasons: string[];
|
||||
staleAfter: Date | null;
|
||||
};
|
||||
|
||||
const CACHE_KEY = 'permission.context.cache';
|
||||
|
||||
function createPermissionRequestCache(): PermissionRequestCache {
|
||||
return {
|
||||
workspaceMember: new Map(),
|
||||
workspacePolicy: new Map(),
|
||||
workspaceRuntime: new Map(),
|
||||
workspaceQuotaRuntime: new Map(),
|
||||
docPolicies: new Map(),
|
||||
docGrants: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
|
||||
export type PermissionDocAction = DocAction | 'Doc.Preview';
|
||||
|
||||
function cacheKey(parts: readonly unknown[]) {
|
||||
return parts.join('\0');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionContextLoader {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly cls?: ClsService
|
||||
) {}
|
||||
|
||||
async load(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
workspaceActions?: PermissionWorkspaceAction[];
|
||||
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
}): Promise<PermissionEvaluationInputV1> {
|
||||
const docs = input.docs ?? [];
|
||||
const [member, workspace, runtime, docPolicies, docGrants] =
|
||||
await Promise.all([
|
||||
input.userId
|
||||
? this.workspaceMember(input.workspaceId, input.userId)
|
||||
: Promise.resolve(null),
|
||||
this.workspacePolicy(input.workspaceId),
|
||||
this.workspaceRuntime(input.workspaceId),
|
||||
this.docPolicies(
|
||||
input.workspaceId,
|
||||
docs.map(doc => doc.docId)
|
||||
),
|
||||
input.userId
|
||||
? this.docGrants(
|
||||
input.workspaceId,
|
||||
docs.map(doc => doc.docId),
|
||||
input.userId
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
|
||||
const workspaceSharingEnabled = workspace?.enableSharing ?? true;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
legacyCompatMode: true,
|
||||
subject: {
|
||||
userId: input.userId,
|
||||
groupIds: [],
|
||||
allowLocal: input.allowLocal,
|
||||
},
|
||||
runtime: {
|
||||
known: runtime.known,
|
||||
stale: runtime.stale,
|
||||
readonly: runtime.readonly,
|
||||
readonlyReason: runtime.readonlyReasons[0],
|
||||
sharingEnabled: workspaceSharingEnabled,
|
||||
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
|
||||
},
|
||||
workspace: {
|
||||
role: toNativeWorkspaceRole(member?.type),
|
||||
memberState: toNativeMemberState(member?.status),
|
||||
public: workspace?.public ?? false,
|
||||
sharingEnabled: workspaceSharingEnabled,
|
||||
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
|
||||
local: !workspace,
|
||||
},
|
||||
workspaceActions: input.workspaceActions,
|
||||
docs: docs.map((doc, index) => {
|
||||
const policy = docPolicies[index];
|
||||
const grant = docGrantMap.get(doc.docId);
|
||||
return {
|
||||
docId: doc.docId,
|
||||
actions: doc.actions,
|
||||
explicitUserRole: toNativeExplicitDocGrantRole(grant?.type),
|
||||
groupGrants: [],
|
||||
groupGrantsEnabled: false,
|
||||
memberDefaultRole: toNativeDocRole(
|
||||
policy?.workspace ?? DocRole.Manager
|
||||
),
|
||||
publicRole: policy?.external === null ? undefined : 'external',
|
||||
visibility: policy?.external === null ? 'private' : 'public',
|
||||
sharingEnabled: workspaceSharingEnabled,
|
||||
previewEnabled: policy?.external !== null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromNewTables(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
workspaceActions?: PermissionWorkspaceAction[];
|
||||
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
}): Promise<PermissionEvaluationInputV1> {
|
||||
const docs = input.docs ?? [];
|
||||
const docIds = docs.map(doc => doc.docId);
|
||||
const [member, workspacePolicy, runtime, docPolicies, docGrants] =
|
||||
await Promise.all([
|
||||
input.userId
|
||||
? this.newWorkspaceMember(input.workspaceId, input.userId)
|
||||
: Promise.resolve(null),
|
||||
this.newWorkspacePolicy(input.workspaceId),
|
||||
this.newWorkspaceRuntime(input.workspaceId),
|
||||
this.newDocPolicies(input.workspaceId, docIds),
|
||||
input.userId
|
||||
? this.newDocGrants(input.workspaceId, docIds, input.userId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const docPolicyMap = new Map(
|
||||
docPolicies.map(policy => [policy.docId, policy])
|
||||
);
|
||||
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
|
||||
const local =
|
||||
!workspacePolicy &&
|
||||
!!input.allowLocal &&
|
||||
!(await this.workspaceExists(input.workspaceId));
|
||||
const sharingEnabled = workspacePolicy?.sharingEnabled ?? true;
|
||||
const urlPreviewEnabled = workspacePolicy?.urlPreviewEnabled ?? false;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
legacyCompatMode: true,
|
||||
subject: {
|
||||
userId: input.userId,
|
||||
groupIds: [],
|
||||
allowLocal: input.allowLocal,
|
||||
},
|
||||
runtime: {
|
||||
known: runtime.known,
|
||||
stale: runtime.stale,
|
||||
readonly: runtime.readonly,
|
||||
readonlyReason: runtime.readonlyReasons[0],
|
||||
sharingEnabled,
|
||||
urlPreviewEnabled,
|
||||
},
|
||||
workspace: {
|
||||
role: member?.role,
|
||||
memberState: member?.state === 'active' ? 'active' : undefined,
|
||||
public: workspacePolicy?.visibility === 'public',
|
||||
sharingEnabled,
|
||||
urlPreviewEnabled,
|
||||
local,
|
||||
},
|
||||
workspaceActions: input.workspaceActions,
|
||||
docs: docs.map(doc => {
|
||||
const policy = docPolicyMap.get(doc.docId);
|
||||
const grant = docGrantMap.get(doc.docId);
|
||||
const visibility = policy?.visibility ?? 'private';
|
||||
const publicRole = policy?.publicRole ?? undefined;
|
||||
return {
|
||||
docId: doc.docId,
|
||||
actions: doc.actions,
|
||||
explicitUserRole: grant?.role,
|
||||
groupGrants: [],
|
||||
groupGrantsEnabled: false,
|
||||
memberDefaultRole:
|
||||
policy?.memberDefaultRole ??
|
||||
workspacePolicy?.memberDefaultDocRole ??
|
||||
'manager',
|
||||
publicRole: publicRole === 'external' ? 'external' : undefined,
|
||||
visibility,
|
||||
sharingEnabled,
|
||||
previewEnabled:
|
||||
visibility === 'public' ||
|
||||
policy?.urlPreviewEnabled ||
|
||||
urlPreviewEnabled,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private get cache(): PermissionRequestCache {
|
||||
if (!this.cls) {
|
||||
return createPermissionRequestCache();
|
||||
}
|
||||
|
||||
if (typeof this.cls.isActive === 'function' && !this.cls.isActive()) {
|
||||
return createPermissionRequestCache();
|
||||
}
|
||||
|
||||
const existing = this.cls.get(CACHE_KEY) as
|
||||
| PermissionRequestCache
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = createPermissionRequestCache();
|
||||
this.cls.set(CACHE_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private memo<T>(
|
||||
map: Map<string, Promise<T> | T>,
|
||||
key: string,
|
||||
load: () => Promise<T>
|
||||
) {
|
||||
const cached = map.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
const promise = load();
|
||||
map.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private workspaceMember(workspaceId: string, userId: string) {
|
||||
return this.memo(
|
||||
this.cache.workspaceMember,
|
||||
cacheKey([workspaceId, userId]),
|
||||
() => this.models.workspaceUser.get(workspaceId, userId)
|
||||
);
|
||||
}
|
||||
|
||||
private workspacePolicy(workspaceId: string) {
|
||||
return this.memo(this.cache.workspacePolicy, workspaceId, () =>
|
||||
this.models.workspace.get(workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
private async workspaceRuntime(workspaceId: string) {
|
||||
return this.memo(this.cache.workspaceRuntime, workspaceId, () =>
|
||||
this.models.workspaceRuntimeState.get(workspaceId).then(async state => {
|
||||
if (state.known || !state.stale) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const quotaState = await this.newWorkspaceRuntime(workspaceId);
|
||||
if (!quotaState.known) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
known: quotaState.known,
|
||||
stale: quotaState.stale,
|
||||
readonly: quotaState.readonly,
|
||||
readonlyReasons: quotaState.readonlyReasons,
|
||||
updatedAt: null,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: quotaState.staleAfter,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
invalidateWorkspaceQuotaRuntime(workspaceId: string) {
|
||||
this.cache.workspaceQuotaRuntime.delete(workspaceId);
|
||||
}
|
||||
|
||||
private newWorkspaceRuntime(workspaceId: string) {
|
||||
return this.memo(
|
||||
this.cache.workspaceQuotaRuntime,
|
||||
workspaceId,
|
||||
async () => {
|
||||
const rows = await this.db.$queryRaw<NewWorkspaceRuntimeState[]>`
|
||||
SELECT
|
||||
known,
|
||||
stale,
|
||||
readonly,
|
||||
readonly_reasons AS "readonlyReasons",
|
||||
stale_after AS "staleAfter"
|
||||
FROM effective_workspace_quota_states
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
LIMIT 1
|
||||
`;
|
||||
const state = rows[0];
|
||||
if (!state) {
|
||||
return {
|
||||
known: false,
|
||||
stale: true,
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
staleAfter: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
stale:
|
||||
state.stale ||
|
||||
(state.staleAfter !== null && state.staleAfter <= new Date()),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private docPolicies(workspaceId: string, docIds: string[]) {
|
||||
const uniqueDocIds = [...new Set(docIds)];
|
||||
return this.memo(
|
||||
this.cache.docPolicies,
|
||||
cacheKey([workspaceId, ...uniqueDocIds]),
|
||||
() => this.models.doc.findDefaultRoles(workspaceId, uniqueDocIds)
|
||||
);
|
||||
}
|
||||
|
||||
private docGrants(workspaceId: string, docIds: string[], userId: string) {
|
||||
const uniqueDocIds = [...new Set(docIds)];
|
||||
return this.memo(
|
||||
this.cache.docGrants,
|
||||
cacheKey([workspaceId, userId, ...uniqueDocIds]),
|
||||
() => this.models.docUser.findMany(workspaceId, uniqueDocIds, userId)
|
||||
);
|
||||
}
|
||||
|
||||
private async newWorkspaceMember(workspaceId: string, userId: string) {
|
||||
const rows = await this.db.$queryRaw<NewWorkspaceMemberRow[]>`
|
||||
SELECT role, state
|
||||
FROM workspace_members
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
AND user_id = ${userId}
|
||||
AND state = 'active'
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async newWorkspacePolicy(workspaceId: string) {
|
||||
const rows = await this.db.$queryRaw<NewWorkspacePolicyRow[]>`
|
||||
SELECT
|
||||
visibility,
|
||||
sharing_enabled AS "sharingEnabled",
|
||||
url_preview_enabled AS "urlPreviewEnabled",
|
||||
member_default_doc_role AS "memberDefaultDocRole"
|
||||
FROM workspace_access_policies
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async workspaceExists(workspaceId: string) {
|
||||
const workspace = await this.db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!workspace;
|
||||
}
|
||||
|
||||
private async newDocPolicies(workspaceId: string, docIds: string[]) {
|
||||
if (docIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await this.db.$queryRaw<NewDocPolicyRow[]>`
|
||||
SELECT
|
||||
doc_id AS "docId",
|
||||
visibility,
|
||||
public_role AS "publicRole",
|
||||
member_default_role AS "memberDefaultRole",
|
||||
url_preview_enabled AS "urlPreviewEnabled"
|
||||
FROM doc_access_policies
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
AND doc_id = ANY(${[...new Set(docIds)]})
|
||||
`;
|
||||
}
|
||||
|
||||
private async newDocGrants(
|
||||
workspaceId: string,
|
||||
docIds: string[],
|
||||
userId: string
|
||||
) {
|
||||
if (docIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await this.db.$queryRaw<NewDocGrantRow[]>`
|
||||
SELECT doc_id AS "docId", role
|
||||
FROM doc_grants
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
AND principal_type = 'user'
|
||||
AND principal_id = ${userId}
|
||||
AND doc_id = ANY(${[...new Set(docIds)]})
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||
|
||||
import type {
|
||||
PermissionDocRole,
|
||||
PermissionEvaluationInputV1,
|
||||
PermissionEvaluationOutputV1,
|
||||
PermissionWorkspaceRole,
|
||||
} from '../../native';
|
||||
import { DocRole, WorkspaceRole } from './types';
|
||||
|
||||
export type PermissionRuntimeState = NonNullable<
|
||||
PermissionEvaluationInputV1['runtime']
|
||||
>;
|
||||
|
||||
export type PermissionWorkspaceContext = NonNullable<
|
||||
PermissionEvaluationInputV1['workspace']
|
||||
>;
|
||||
|
||||
export type PermissionDocContext = NonNullable<
|
||||
NonNullable<PermissionEvaluationInputV1['docs']>[number]
|
||||
>;
|
||||
|
||||
export type PermissionLegacyRoleBoundary = {
|
||||
resourceOwnerRole: PermissionDocRole | PermissionWorkspaceRole | null;
|
||||
effectiveRole: PermissionDocRole | PermissionWorkspaceRole | null;
|
||||
legacyApiRole: DocRole | WorkspaceRole | null;
|
||||
};
|
||||
|
||||
const WORKSPACE_ROLE_TO_NATIVE = new Map<
|
||||
WorkspaceRole,
|
||||
PermissionWorkspaceRole
|
||||
>([
|
||||
[WorkspaceRole.External, 'external'],
|
||||
[WorkspaceRole.Collaborator, 'member'],
|
||||
[WorkspaceRole.Admin, 'admin'],
|
||||
[WorkspaceRole.Owner, 'owner'],
|
||||
]);
|
||||
|
||||
const DOC_ROLE_TO_NATIVE = new Map<DocRole, PermissionDocRole>([
|
||||
[DocRole.None, 'none'],
|
||||
[DocRole.External, 'external'],
|
||||
[DocRole.Reader, 'reader'],
|
||||
[DocRole.Commenter, 'commenter'],
|
||||
[DocRole.Editor, 'editor'],
|
||||
[DocRole.Manager, 'manager'],
|
||||
[DocRole.Owner, 'owner'],
|
||||
]);
|
||||
|
||||
const NATIVE_WORKSPACE_ROLE_TO_LEGACY = new Map<
|
||||
PermissionWorkspaceRole,
|
||||
WorkspaceRole
|
||||
>([
|
||||
['external', WorkspaceRole.External],
|
||||
['member', WorkspaceRole.Collaborator],
|
||||
['admin', WorkspaceRole.Admin],
|
||||
['owner', WorkspaceRole.Owner],
|
||||
]);
|
||||
|
||||
const NATIVE_DOC_ROLE_TO_LEGACY = new Map<PermissionDocRole, DocRole>([
|
||||
['none', DocRole.None],
|
||||
['external', DocRole.External],
|
||||
['reader', DocRole.Reader],
|
||||
['commenter', DocRole.Commenter],
|
||||
['editor', DocRole.Editor],
|
||||
['manager', DocRole.Manager],
|
||||
['owner', DocRole.Owner],
|
||||
]);
|
||||
|
||||
export function toNativeWorkspaceRole(role: WorkspaceRole | null | undefined) {
|
||||
return role == null ? undefined : WORKSPACE_ROLE_TO_NATIVE.get(role);
|
||||
}
|
||||
|
||||
export function toNativeDocRole(role: DocRole | null | undefined) {
|
||||
return role == null ? undefined : DOC_ROLE_TO_NATIVE.get(role);
|
||||
}
|
||||
|
||||
export function toNativeExplicitDocGrantRole(role: DocRole | null | undefined) {
|
||||
if (role === DocRole.None || role === DocRole.External) {
|
||||
return undefined;
|
||||
}
|
||||
return toNativeDocRole(role);
|
||||
}
|
||||
|
||||
export function toNativeMemberState(status?: WorkspaceMemberStatus | null) {
|
||||
switch (status) {
|
||||
case WorkspaceMemberStatus.Accepted:
|
||||
return 'active';
|
||||
case WorkspaceMemberStatus.UnderReview:
|
||||
return 'waiting_review';
|
||||
case WorkspaceMemberStatus.AllocatingSeat:
|
||||
case WorkspaceMemberStatus.NeedMoreSeat:
|
||||
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
|
||||
return 'waiting_seat';
|
||||
case WorkspaceMemberStatus.Pending:
|
||||
return 'pending';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceLegacyBoundary(
|
||||
workspace: PermissionEvaluationOutputV1['workspace']
|
||||
): PermissionLegacyRoleBoundary {
|
||||
const effectiveRole = workspace.effectiveRole ?? null;
|
||||
return {
|
||||
resourceOwnerRole: workspace.resourceOwnerRole ?? null,
|
||||
effectiveRole,
|
||||
legacyApiRole: effectiveRole
|
||||
? (NATIVE_WORKSPACE_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function docLegacyBoundary(
|
||||
doc: PermissionEvaluationOutputV1['docs'][number]
|
||||
): PermissionLegacyRoleBoundary {
|
||||
const effectiveRole = doc.effectiveRole ?? null;
|
||||
return {
|
||||
resourceOwnerRole: doc.resourceOwnerRole ?? null,
|
||||
effectiveRole,
|
||||
legacyApiRole: effectiveRole
|
||||
? (NATIVE_DOC_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
Resource,
|
||||
ResourceAction,
|
||||
ResourceRole,
|
||||
ResourceType,
|
||||
} from './resource';
|
||||
|
||||
const ACTION_CHECKER_PROVIDERS = new Map<ResourceType, AccessController<any>>();
|
||||
|
||||
function registerAccessController<Type extends ResourceType>(
|
||||
type: Type,
|
||||
provider: AccessController<Type>
|
||||
) {
|
||||
ACTION_CHECKER_PROVIDERS.set(type, provider);
|
||||
}
|
||||
|
||||
export function getAccessController<Type extends ResourceType>(
|
||||
type: Type
|
||||
): AccessController<Type> {
|
||||
const provider = ACTION_CHECKER_PROVIDERS.get(type);
|
||||
if (!provider) {
|
||||
throw new Error(`No action checker provider for type ${type}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export abstract class AccessController<
|
||||
Type extends ResourceType,
|
||||
> implements OnModuleInit {
|
||||
protected abstract readonly type: Type;
|
||||
protected logger = new Logger(AccessController.name);
|
||||
|
||||
onModuleInit() {
|
||||
registerAccessController(this.type, this);
|
||||
}
|
||||
|
||||
abstract assert(
|
||||
resource: Resource<Type>,
|
||||
action: ResourceAction<Type>
|
||||
): Promise<void>;
|
||||
|
||||
abstract can(
|
||||
resource: Resource<Type>,
|
||||
action: ResourceAction<Type>
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract role(resource: Resource<Type>): Promise<{
|
||||
role: ResourceRole<Type> | null;
|
||||
permissions: Record<ResourceAction<Type>, boolean>;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
import { metrics } from '../../base';
|
||||
import type { PermissionEvaluationOutputV1 } from '../../native';
|
||||
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
|
||||
import {
|
||||
PermissionContextLoader,
|
||||
type PermissionDocAction,
|
||||
type PermissionWorkspaceAction,
|
||||
} from './context-loader';
|
||||
import { PermissionService } from './service';
|
||||
import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||
|
||||
export const PERMISSION_SHADOW_MISMATCH_CATEGORIES = [
|
||||
'legacy_compat_delta',
|
||||
'projection',
|
||||
'rust_rule',
|
||||
'loader',
|
||||
'sql_predicate',
|
||||
'legacy_api_role_mapping',
|
||||
'preview_read_mapping',
|
||||
'runtime_state',
|
||||
'projection_or_loader',
|
||||
] as const;
|
||||
|
||||
type PermissionShadowMismatchCategory =
|
||||
(typeof PERMISSION_SHADOW_MISMATCH_CATEGORIES)[number];
|
||||
|
||||
@Injectable()
|
||||
export class PermissionDiagnosticService {
|
||||
constructor(
|
||||
private readonly loader: PermissionContextLoader,
|
||||
private readonly permission: PermissionService,
|
||||
@Optional()
|
||||
@Inject(PermissionSqlPredicateBuilder)
|
||||
private readonly sqlPredicate = new PermissionSqlPredicateBuilder()
|
||||
) {}
|
||||
|
||||
async shadowDocPermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||
allowLocal?: boolean;
|
||||
expectedDeltaCategory?: PermissionShadowMismatchCategory;
|
||||
}) {
|
||||
const [legacyOutput, newOutput] = await Promise.all([
|
||||
this.loader.load(input).then(input => this.permission.evaluate(input)),
|
||||
this.loader
|
||||
.loadFromNewTables(input)
|
||||
.then(input => this.permission.evaluate(input)),
|
||||
]);
|
||||
|
||||
const legacy = legacyOutput.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
...docLegacyBoundary(doc),
|
||||
decisions: doc.decisions,
|
||||
}));
|
||||
const current = newOutput.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
...docLegacyBoundary(doc),
|
||||
decisions: doc.decisions,
|
||||
}));
|
||||
const matched = JSON.stringify(legacy) === JSON.stringify(current);
|
||||
const mismatchType = matched
|
||||
? null
|
||||
: (input.expectedDeltaCategory ??
|
||||
this.classifyDocShadowMismatch(legacy, current));
|
||||
this.recordShadowMismatch('doc', mismatchType);
|
||||
|
||||
return {
|
||||
matched,
|
||||
legacy,
|
||||
current,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowWorkspacePermissions(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
actions: PermissionWorkspaceAction[];
|
||||
allowLocal?: boolean;
|
||||
expectedDeltaCategory?: PermissionShadowMismatchCategory;
|
||||
}) {
|
||||
const legacyInput = {
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceActions: input.actions,
|
||||
allowLocal: input.allowLocal,
|
||||
};
|
||||
const [legacyOutput, newOutput] = await Promise.all([
|
||||
this.loader
|
||||
.load(legacyInput)
|
||||
.then(input => this.permission.evaluate(input)),
|
||||
this.loader
|
||||
.loadFromNewTables(legacyInput)
|
||||
.then(input => this.permission.evaluate(input)),
|
||||
]);
|
||||
|
||||
const legacy = {
|
||||
...workspaceLegacyBoundary(legacyOutput.workspace),
|
||||
decisions: legacyOutput.workspace.decisions,
|
||||
};
|
||||
const current = {
|
||||
...workspaceLegacyBoundary(newOutput.workspace),
|
||||
decisions: newOutput.workspace.decisions,
|
||||
};
|
||||
const matched = JSON.stringify(legacy) === JSON.stringify(current);
|
||||
const mismatchType = matched
|
||||
? null
|
||||
: (input.expectedDeltaCategory ??
|
||||
this.classifyShadowMismatch(legacyOutput, newOutput));
|
||||
this.recordShadowMismatch('workspace', mismatchType);
|
||||
|
||||
return {
|
||||
matched,
|
||||
legacy,
|
||||
current,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowSqlDocRead(input: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
docs: Array<{ docId: string }>;
|
||||
sqlReadableDocIds: string[];
|
||||
allowLocal?: boolean;
|
||||
expectedDeltaCategory?: PermissionShadowMismatchCategory;
|
||||
}) {
|
||||
const rustOutput = this.permission.evaluate(
|
||||
await this.loader.loadFromNewTables({
|
||||
userId: input.userId,
|
||||
workspaceId: input.workspaceId,
|
||||
docs: input.docs.map(doc => ({
|
||||
docId: doc.docId,
|
||||
actions: ['Doc.Read'],
|
||||
})),
|
||||
allowLocal: input.allowLocal,
|
||||
})
|
||||
);
|
||||
const rustReadable = new Set(
|
||||
rustOutput.docs
|
||||
.filter(doc => doc.decisions[0]?.allowed)
|
||||
.map(doc => doc.docId)
|
||||
);
|
||||
const sqlReadable = new Set(input.sqlReadableDocIds);
|
||||
const missingInSql = [...rustReadable].filter(id => !sqlReadable.has(id));
|
||||
const extraInSql = [...sqlReadable].filter(id => !rustReadable.has(id));
|
||||
const mismatchType =
|
||||
missingInSql.length || extraInSql.length
|
||||
? (input.expectedDeltaCategory ?? 'sql_predicate')
|
||||
: null;
|
||||
this.recordShadowMismatch('sql_predicate', mismatchType);
|
||||
|
||||
return {
|
||||
matched: mismatchType === null,
|
||||
predicate: this.sqlPredicate.docReadableByNewTables({
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
action: 'Doc.Read',
|
||||
}),
|
||||
rustReadableDocIds: [...rustReadable],
|
||||
sqlReadableDocIds: [...sqlReadable],
|
||||
missingInSql,
|
||||
extraInSql,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowPreviewDoc(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const result = await this.shadowDocPermissions({
|
||||
...input,
|
||||
docs: [{ docId: input.docId, actions: ['Doc.Preview', 'Doc.Read'] }],
|
||||
});
|
||||
const legacy = result.legacy[0];
|
||||
const current = result.current[0];
|
||||
const legacyPreviewAllowed = legacy?.decisions.find(
|
||||
decision => decision.action === 'Doc.Preview'
|
||||
)?.allowed;
|
||||
const legacyReadAllowed = legacy?.decisions.find(
|
||||
decision => decision.action === 'Doc.Read'
|
||||
)?.allowed;
|
||||
const previewAllowed = current?.decisions.find(
|
||||
decision => decision.action === 'Doc.Preview'
|
||||
)?.allowed;
|
||||
const readAllowed = current?.decisions.find(
|
||||
decision => decision.action === 'Doc.Read'
|
||||
)?.allowed;
|
||||
const mismatchType =
|
||||
legacyPreviewAllowed !== previewAllowed ||
|
||||
(previewAllowed && readAllowed && !legacyReadAllowed)
|
||||
? 'preview_read_mapping'
|
||||
: result.mismatchType;
|
||||
this.recordShadowMismatch('preview', mismatchType);
|
||||
|
||||
return {
|
||||
...result,
|
||||
matched: result.matched && mismatchType === null,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
async shadowPreviewWorkspace(input: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
allowLocal?: boolean;
|
||||
}) {
|
||||
const result = await this.shadowWorkspacePermissions({
|
||||
...input,
|
||||
actions: ['Workspace.Preview', 'Workspace.Read'],
|
||||
});
|
||||
const legacyPreviewAllowed = result.legacy.decisions.find(
|
||||
decision => decision.action === 'Workspace.Preview'
|
||||
)?.allowed;
|
||||
const legacyReadAllowed = result.legacy.decisions.find(
|
||||
decision => decision.action === 'Workspace.Read'
|
||||
)?.allowed;
|
||||
const previewAllowed = result.current.decisions.find(
|
||||
decision => decision.action === 'Workspace.Preview'
|
||||
)?.allowed;
|
||||
const readAllowed = result.current.decisions.find(
|
||||
decision => decision.action === 'Workspace.Read'
|
||||
)?.allowed;
|
||||
const mismatchType =
|
||||
legacyPreviewAllowed !== previewAllowed ||
|
||||
(previewAllowed && readAllowed && !legacyReadAllowed)
|
||||
? 'preview_read_mapping'
|
||||
: result.mismatchType;
|
||||
this.recordShadowMismatch('preview', mismatchType);
|
||||
|
||||
return {
|
||||
...result,
|
||||
matched: result.matched && mismatchType === null,
|
||||
mismatchType,
|
||||
};
|
||||
}
|
||||
|
||||
private classifyShadowMismatch(
|
||||
legacyOutput: PermissionEvaluationOutputV1,
|
||||
newOutput: PermissionEvaluationOutputV1
|
||||
) {
|
||||
if (JSON.stringify(legacyOutput) === JSON.stringify(newOutput)) {
|
||||
return null;
|
||||
}
|
||||
const legacyRestrictions =
|
||||
JSON.stringify(legacyOutput).includes('runtime_');
|
||||
const newRestrictions = JSON.stringify(newOutput).includes('runtime_');
|
||||
if (legacyRestrictions || newRestrictions) {
|
||||
return 'runtime_state';
|
||||
}
|
||||
if (legacyOutput.docs.length !== newOutput.docs.length) {
|
||||
return 'loader';
|
||||
}
|
||||
if (JSON.stringify(legacyOutput.docs) !== JSON.stringify(newOutput.docs)) {
|
||||
return 'rust_rule';
|
||||
}
|
||||
return 'projection';
|
||||
}
|
||||
|
||||
private classifyDocShadowMismatch(
|
||||
legacy: Array<
|
||||
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
|
||||
>,
|
||||
current: Array<
|
||||
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
|
||||
>
|
||||
) {
|
||||
if (JSON.stringify(legacy) === JSON.stringify(current)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const legacyApi = legacy.map(doc => ({
|
||||
effectiveRole: doc.effectiveRole,
|
||||
legacyApiRole: doc.legacyApiRole,
|
||||
resourceOwnerRole: doc.resourceOwnerRole,
|
||||
}));
|
||||
const currentApi = current.map(doc => ({
|
||||
effectiveRole: doc.effectiveRole,
|
||||
legacyApiRole: doc.legacyApiRole,
|
||||
resourceOwnerRole: doc.resourceOwnerRole,
|
||||
}));
|
||||
if (JSON.stringify(legacyApi) !== JSON.stringify(currentApi)) {
|
||||
return 'legacy_api_role_mapping';
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(legacy).includes('runtime_') ||
|
||||
JSON.stringify(current).includes('runtime_')
|
||||
) {
|
||||
return 'runtime_state';
|
||||
}
|
||||
|
||||
if (legacy.length !== current.length) {
|
||||
return 'loader';
|
||||
}
|
||||
|
||||
const legacyDecisions = legacy.map(doc => doc.decisions);
|
||||
const currentDecisions = current.map(doc => doc.decisions);
|
||||
if (JSON.stringify(legacyDecisions) !== JSON.stringify(currentDecisions)) {
|
||||
return 'rust_rule';
|
||||
}
|
||||
|
||||
return 'projection';
|
||||
}
|
||||
|
||||
private recordShadowMismatch(
|
||||
scope: string,
|
||||
category: PermissionShadowMismatchCategory | null
|
||||
) {
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.permission
|
||||
.counter('shadow_mismatches', {
|
||||
description: 'Permission shadow-read mismatch count',
|
||||
})
|
||||
.add(1, { scope, category });
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DocActionDenied } from '../../base';
|
||||
import { AccessController, getAccessController } from './controller';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
DocAction,
|
||||
docActionRequiredRole,
|
||||
DocRole,
|
||||
mapDocRoleToPermissions,
|
||||
} from './types';
|
||||
import { WorkspaceAccessController } from './workspace';
|
||||
|
||||
@Injectable()
|
||||
export class DocAccessController extends AccessController<'doc'> {
|
||||
protected readonly type = 'doc';
|
||||
constructor(private readonly policy: WorkspacePolicyService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async role(resource: Resource<'doc'>) {
|
||||
const role = await this.getRole(resource);
|
||||
const permissions = await this.policy.applyDocPermissions(
|
||||
resource.workspaceId,
|
||||
mapDocRoleToPermissions(role)
|
||||
);
|
||||
const sharingAllowed = await this.policy.canPublishDoc(
|
||||
resource.workspaceId
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
permissions['Doc.Publish'] = false;
|
||||
}
|
||||
|
||||
return { role, permissions };
|
||||
}
|
||||
|
||||
async can(resource: Resource<'doc'>, action: DocAction) {
|
||||
const { permissions, role } = await this.role(resource);
|
||||
const allow = permissions[action] || false;
|
||||
|
||||
if (!allow) {
|
||||
this.logger.debug('Doc access check failed', {
|
||||
action,
|
||||
resource,
|
||||
role,
|
||||
requiredRole: docActionRequiredRole(action),
|
||||
});
|
||||
}
|
||||
|
||||
return allow;
|
||||
}
|
||||
|
||||
async assert(resource: Resource<'doc'>, action: DocAction) {
|
||||
const allow = await this.can(resource, action);
|
||||
|
||||
if (!allow) {
|
||||
throw new DocActionDenied({
|
||||
docId: resource.docId,
|
||||
spaceId: resource.workspaceId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getRole(payload: Resource<'doc'>): Promise<DocRole | null> {
|
||||
const workspaceController = getAccessController(
|
||||
'ws'
|
||||
) as WorkspaceAccessController;
|
||||
const docRoles = await workspaceController.getDocRoles(payload, [
|
||||
payload.docId,
|
||||
]);
|
||||
return docRoles[0];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user