fix(server): member loading (#15156)

#### PR Dependency Tree


* **PR #15156** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Fixed Stripe subscription syncing for Team plans to update the correct
existing local subscription (avoiding duplicates) while refreshing
quantity and billing/trial period details.
* **UI/UX Improvements**
* Improved workspace member list loading/error states with shared UI
components and steadier pagination behavior.
  * Refined fallback styling for cleaner, more stable layout.
* **Tests**
* Expanded subscription and projection coverage and adjusted
seat-allocation/e2e assertions to be more robust.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-26 21:29:15 +08:00
committed by GitHub
parent 8e036a2f38
commit 4a7c931eca
11 changed files with 248 additions and 110 deletions

View File

@@ -187,6 +187,7 @@ e2e('should allocate seats', async t => {
source: 'Link', source: 'Link',
}); });
const invitationCount = app.queue.count('notification.sendInvitation');
await app.eventBus.emitAsync('workspace.members.allocateSeats', { await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id, workspaceId: workspace.id,
quantity: 5, quantity: 5,
@@ -206,7 +207,7 @@ e2e('should allocate seats', async t => {
WorkspaceMemberStatus.Accepted WorkspaceMemberStatus.Accepted
); );
t.is(app.queue.count('notification.sendInvitation'), 1); t.is(app.queue.count('notification.sendInvitation') - invitationCount, 1);
}); });
e2e('should set all rests to NeedMoreSeat', async t => { e2e('should set all rests to NeedMoreSeat', async t => {

View File

@@ -1552,6 +1552,48 @@ test('should be able to create team subscription', async t => {
t.is(subInDB?.stripeSubscriptionId, sub.id); t.is(subInDB?.stripeSubscriptionId, sub.id);
}); });
test('should replace old team subscription row when stripe creates a new subscription', async t => {
const { service, db } = t.context;
const old = await db.subscription.create({
data: {
targetId: 'ws_1',
stripeSubscriptionId: 'sub_old_team',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Canceled,
start: new Date('2026-03-26T08:23:57.000Z'),
end: new Date('2027-03-26T08:23:57.000Z'),
quantity: 24,
},
});
await service.saveStripeSubscription({
...teamSub,
id: 'sub_new_team',
status: SubscriptionStatus.Active,
items: {
...teamSub.items,
data: [
{
...teamSub.items.data[0],
quantity: 11,
},
],
},
});
const subscriptions = await db.subscription.findMany({
where: { targetId: 'ws_1', plan: SubscriptionPlan.Team },
});
t.is(subscriptions.length, 1);
t.is(subscriptions[0].id, old.id);
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new_team');
t.is(subscriptions[0].status, SubscriptionStatus.Active);
t.is(subscriptions[0].quantity, 11);
});
test('should be able to update team subscription', async t => { test('should be able to update team subscription', async t => {
const { service, db, event } = t.context; const { service, db, event } = t.context;
@@ -1586,6 +1628,77 @@ test('should be able to update team subscription', async t => {
); );
}); });
test('should persist mutable team subscription fields on same stripe subscription update', async t => {
const { service, db } = t.context;
await service.saveStripeSubscription(teamSub);
await service.saveStripeSubscription({
...teamSub,
current_period_start: 1780000000,
current_period_end: 1811536000,
trial_start: 1780000000,
trial_end: 1780604800,
items: {
...teamSub.items,
data: [
{
...teamSub.items.data[0],
quantity: 9,
price: {
...PRICES[TEAM_YEARLY],
lookup_key: TEAM_YEARLY,
},
},
],
},
});
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
});
const entitlement = await db.entitlement.findFirst({
where: {
source: 'cloud_subscription',
subjectId: teamSub.id,
},
});
const providerFact = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: teamSub.id,
},
},
});
t.like(subInDB, {
recurring: SubscriptionRecurring.Yearly,
quantity: 9,
start: new Date(1780000000 * 1000),
end: new Date(1811536000 * 1000),
trialStart: new Date(1780000000 * 1000),
trialEnd: new Date(1780604800 * 1000),
});
t.like(entitlement, {
plan: 'team',
quantity: 9,
startsAt: new Date(1780000000 * 1000),
expiresAt: new Date(1811536000 * 1000),
});
t.like(providerFact, {
recurring: SubscriptionRecurring.Yearly,
externalPriceId: TEAM_YEARLY,
currency: 'usd',
amount: 14400,
quantity: 9,
periodStart: new Date(1780000000 * 1000),
periodEnd: new Date(1811536000 * 1000),
trialStart: new Date(1780000000 * 1000),
trialEnd: new Date(1780604800 * 1000),
});
});
test('should suspend on dispute and restore when dispute won', async t => { test('should suspend on dispute and restore when dispute won', async t => {
const { service, db, stripe, event } = t.context; const { service, db, stripe, event } = t.context;

View File

@@ -76,15 +76,15 @@ test('checker reports missing legacy projection and stale state', async t => {
const user = await t.context.models.user.create({ const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`, email: `${randomUUID()}@affine.pro`,
}); });
await t.context.entitlement.upsertFromCloudSubscription({ await t.context.entitlement.upsertFromCloudSubscription(
{
targetId: user.id, targetId: user.id,
plan: 'pro', plan: 'pro',
recurring: SubscriptionRecurring.Monthly, recurring: SubscriptionRecurring.Monthly,
status: 'active', status: 'active',
}); },
await t.context.db.subscription.delete({ { emit: false }
where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, );
});
await t.context.db.effectiveUserQuotaState.update({ await t.context.db.effectiveUserQuotaState.update({
where: { userId: user.id }, where: { userId: user.id },
data: { data: {

View File

@@ -48,18 +48,24 @@ test('projects user entitlement to legacy user features and subscriptions', asyn
email: `${randomUUID()}@affine.pro`, email: `${randomUUID()}@affine.pro`,
}); });
await t.context.entitlement.upsertFromCloudSubscription({ await t.context.entitlement.upsertFromCloudSubscription(
{
targetId: user.id, targetId: user.id,
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly, recurring: SubscriptionRecurring.Yearly,
status: 'active', status: 'active',
}); },
await t.context.entitlement.upsertFromCloudSubscription({ { emit: false }
);
await t.context.entitlement.upsertFromCloudSubscription(
{
targetId: user.id, targetId: user.id,
plan: SubscriptionPlan.AI, plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly, recurring: SubscriptionRecurring.Monthly,
status: 'active', status: 'active',
}); },
{ emit: false }
);
await t.context.projection.onEntitlementChanged({ await t.context.projection.onEntitlementChanged({
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client'; import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
import { omit, pick } from 'lodash-es'; import { omit } from 'lodash-es';
import { z } from 'zod'; import { z } from 'zod';
import { SubscriptionPlanNotFound, URLHelper } from '../../../base'; import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
@@ -163,13 +163,14 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
where: { where: {
stripeSubscriptionId: stripeSubscription.id, stripeSubscriptionId: stripeSubscription.id,
}, },
data: pick(subscriptionData, [ data: {
'status', ...omit(subscriptionData, ['provider', 'iapStore']),
'stripeScheduleId', provider: Provider.stripe,
'nextBillAt', iapStore: null,
'canceledAt', rcEntitlement: null,
'end', rcProductId: null,
]), rcExternalRef: null,
},
}); });
await this.upsertStripeProviderSubscription( await this.upsertStripeProviderSubscription(
saved.targetId, saved.targetId,

View File

@@ -5,7 +5,7 @@ import {
Provider, Provider,
UserStripeCustomer, UserStripeCustomer,
} from '@prisma/client'; } from '@prisma/client';
import { omit, pick } from 'lodash-es'; import { omit } from 'lodash-es';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
@@ -272,13 +272,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
const saved = existingByStripeId const saved = existingByStripeId
? await this.db.subscription.update({ ? await this.db.subscription.update({
where: { id: existingByStripeId.id }, where: { id: existingByStripeId.id },
data: pick(subscriptionData, [ data: {
'status', ...omit(subscriptionData, ['provider', 'iapStore']),
'stripeScheduleId', provider: Provider.stripe,
'nextBillAt', iapStore: null,
'canceledAt', rcEntitlement: null,
'end', rcProductId: null,
]), rcExternalRef: null,
},
}) })
: await this.db.subscription.upsert({ : await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts. // TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client'; import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
import { omit, pick } from 'lodash-es'; import { omit } from 'lodash-es';
import { z } from 'zod'; import { z } from 'zod';
import { import {
@@ -163,22 +163,38 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}); });
} }
const saved = await this.db.subscription.upsert({ const existingByStripeId = await this.db.subscription.findUnique({
where: { stripeSubscriptionId: stripeSubscription.id },
});
const saved = existingByStripeId
? await this.db.subscription.update({
where: { id: existingByStripeId.id },
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
})
: await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts. // TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup. // TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: { where: {
provider: Provider.stripe, targetId_plan: {
stripeSubscriptionId: stripeSubscription.id, targetId: workspaceId,
plan: lookupKey.plan,
},
}, },
update: { update: {
...pick(subscriptionData, [ ...omit(subscriptionData, ['provider', 'iapStore']),
'status', provider: Provider.stripe,
'stripeScheduleId', iapStore: null,
'nextBillAt', rcEntitlement: null,
'canceledAt', rcProductId: null,
'quantity', rcExternalRef: null,
'end',
]),
}, },
create: { create: {
targetId: workspaceId, targetId: workspaceId,

View File

@@ -1,4 +1,4 @@
import { Button, Loading, notify, useConfirmModal } from '@affine/component'; import { Button, notify, useConfirmModal } from '@affine/component';
import { import {
InviteTeamMemberModal, InviteTeamMemberModal,
type InviteTeamMemberModalProps, type InviteTeamMemberModalProps,
@@ -31,7 +31,7 @@ import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { SettingState } from '../../types'; import type { SettingState } from '../../types';
import { MemberList } from './member-list'; import { MemberList, MemberListError, MemberListFallback } from './member-list';
import * as styles from './styles.css'; import * as styles from './styles.css';
const parseCSV = async (blob: Blob): Promise<string[]> => { const parseCSV = async (blob: Blob): Promise<string[]> => {
@@ -290,13 +290,7 @@ export const CloudWorkspaceMembersPanel = ({
if (isLoading) { if (isLoading) {
return <MembersPanelFallback />; return <MembersPanelFallback />;
} else { } else {
return ( return <MemberListError error={error} memberCount={1} />;
<span className={styles.errorStyle}>
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load members'}
</span>
);
} }
} }
@@ -358,30 +352,6 @@ export const MembersPanelFallback = () => {
); );
}; };
const MemberListFallback = ({ memberCount }: { memberCount?: number }) => {
// prevent page jitter
const height = useMemo(() => {
if (memberCount) {
// height and margin-bottom
return memberCount * 58 + (memberCount - 1) * 6;
}
return 'auto';
}, [memberCount]);
const t = useI18n();
return (
<div
style={{
height,
}}
className={styles.membersFallback}
>
<Loading size={20} />
<span>{t['com.affine.settings.member.loading']()}</span>
</div>
);
};
const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => { const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => {
const t = useI18n(); const t = useI18n();

View File

@@ -45,7 +45,6 @@ export const MemberList = ({
const handlePageChange = useCallback( const handlePageChange = useCallback(
(_: number, pageNum: number) => { (_: number, pageNum: number) => {
membersService.members.setPageNum(pageNum); membersService.members.setPageNum(pageNum);
membersService.members.revalidate();
}, },
[membersService] [membersService]
); );
@@ -57,7 +56,7 @@ export const MemberList = ({
return ( return (
<div> <div>
{pageMembers === undefined ? ( {pageMembers === undefined ? (
isLoading ? ( isLoading || !error ? (
<MemberListFallback <MemberListFallback
memberCount={ memberCount={
memberCount memberCount
@@ -70,11 +69,7 @@ export const MemberList = ({
} }
/> />
) : ( ) : (
<span className={styles.errorStyle}> <MemberListError error={error} />
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load members'}
</span>
) )
) : ( ) : (
pageMembers?.map(member => ( pageMembers?.map(member => (
@@ -278,19 +273,24 @@ const getMemberStatus = (member: Member): I18nString => {
} }
}; };
const getMembersFallbackHeight = (memberCount?: number) => {
if (memberCount) {
// height and margin-bottom
return memberCount * 58 + (memberCount - 1) * 6;
}
return 'auto';
};
export const MemberListFallback = ({ export const MemberListFallback = ({
memberCount, memberCount,
}: { }: {
memberCount?: number; memberCount?: number;
}) => { }) => {
// prevent page jitter // prevent page jitter
const height = useMemo(() => { const height = useMemo(
if (memberCount) { () => getMembersFallbackHeight(memberCount),
// height and margin-bottom [memberCount]
return memberCount * 58 + (memberCount - 1) * 6; );
}
return 'auto';
}, [memberCount]);
const t = useI18n(); const t = useI18n();
return ( return (
@@ -305,3 +305,31 @@ export const MemberListFallback = ({
</div> </div>
); );
}; };
export const MemberListError = ({
error,
memberCount,
}: {
error?: unknown;
memberCount?: number;
}) => {
const height = useMemo(
() => getMembersFallbackHeight(memberCount),
[memberCount]
);
return (
<div
style={{
height,
}}
className={styles.membersFallback}
>
<span className={styles.errorStyle}>
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load members'}
</span>
</div>
);
};

View File

@@ -55,11 +55,12 @@ export const errorStyle = style({
export const membersFallback = style({ export const membersFallback = style({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'flexStart', alignItems: 'center',
color: cssVarV2('text/secondary'), color: cssVarV2('text/secondary'),
gap: '4px', gap: '4px',
padding: '8px', padding: '16px 8px',
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
textAlign: 'center',
}); });
export const memberListItem = style({ export const memberListItem = style({

View File

@@ -66,8 +66,9 @@ export class WorkspaceMembers extends Entity {
} }
private async requestMembers(signal: AbortSignal) { private async requestMembers(signal: AbortSignal) {
this.pageMembers$.setValue(undefined);
this.isLoading$.setValue(true); this.isLoading$.setValue(true);
this.error$.setValue(null);
this.pageMembers$.setValue(undefined);
try { try {
const pageNum = this.pageNum$.value; const pageNum = this.pageNum$.value;
return await this.store.fetchMembers( return await this.store.fetchMembers(