feat: cache workspace subscription + minor fixes (#5705)

This commit is contained in:
Mathias Mogensen 2024-07-08 22:58:04 +02:00 committed by GitHub
parent 00551bd1d2
commit 0802651546
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 411 additions and 182 deletions

View File

@ -1,14 +1,20 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'settings_billing_bloc.freezed.dart';
@ -25,25 +31,19 @@ class SettingsBillingBloc
started: () async {
emit(const SettingsBillingState.loading());
final snapshots = await Future.wait([
UserBackendService.getWorkspaceSubscriptions(),
_service.getBillingPortal(),
]);
FlowyError? error;
final subscription = snapshots.first.fold(
final subscription =
(await UserBackendService.getWorkspaceSubscriptions()).fold(
(s) =>
(s as RepeatedWorkspaceSubscriptionPB)
.items
.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
s.items.firstWhereOrNull((i) => i.workspaceId == workspaceId) ??
WorkspaceSubscriptionPB(
workspaceId: workspaceId,
subscriptionPlan: SubscriptionPlanPB.None,
isActive: true,
),
(e) {
// Not a Cjstomer yet
// Not a Customer yet
if (e.code == ErrorCode.InvalidParams) {
return WorkspaceSubscriptionPB(
workspaceId: workspaceId,
@ -57,46 +57,88 @@ class SettingsBillingBloc
},
);
final billingPortalResult = snapshots.last;
final billingPortal = billingPortalResult.fold(
(s) => s as BillingPortalPB,
(e) {
// Not a customer yet
if (e.code == ErrorCode.InvalidParams) {
return BillingPortalPB();
}
error = e;
return null;
},
);
if (subscription == null || billingPortal == null || error != null) {
if (subscription == null || error != null) {
return emit(SettingsBillingState.error(error: error));
}
if (!_billingPortalCompleter.isCompleted) {
unawaited(_fetchBillingPortal());
unawaited(
_billingPortalCompleter.future.then(
(result) {
result.fold(
(portal) {
_billingPortal = portal;
add(
SettingsBillingEvent.billingPortalFetched(
billingPortal: portal,
),
);
},
(e) => Log.error('Error fetching billing portal: $e'),
);
},
),
);
}
emit(
SettingsBillingState.ready(
subscription: subscription,
billingPortal: billingPortal,
billingPortal: _billingPortal,
),
);
},
billingPortalFetched: (billingPortal) {
state.maybeWhen(
orElse: () {},
ready: (subscription, _) => emit(
SettingsBillingState.ready(
subscription: subscription,
billingPortal: billingPortal,
),
),
);
},
openCustomerPortal: () async {
if (_billingPortalCompleter.isCompleted && _billingPortal != null) {
await afLaunchUrlString(_billingPortal!.url);
}
await _billingPortalCompleter.future;
if (_billingPortal != null) {
await afLaunchUrlString(_billingPortal!.url);
}
},
);
});
}
late final String workspaceId;
late final WorkspaceService _service;
final _billingPortalCompleter =
Completer<FlowyResult<BillingPortalPB, FlowyError>>();
BillingPortalPB? _billingPortal;
Future<void> _fetchBillingPortal() async {
final billingPortalResult = await _service.getBillingPortal();
_billingPortalCompleter.complete(billingPortalResult);
}
}
@freezed
class SettingsBillingEvent with _$SettingsBillingEvent {
const factory SettingsBillingEvent.started() = _Started;
const factory SettingsBillingEvent.billingPortalFetched({
required BillingPortalPB billingPortal,
}) = _BillingPortalFetched;
const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal;
}
@freezed
class SettingsBillingState with _$SettingsBillingState {
class SettingsBillingState extends Equatable with _$SettingsBillingState {
const SettingsBillingState._();
const factory SettingsBillingState.initial() = _Initial;
const factory SettingsBillingState.loading() = _Loading;
@ -109,4 +151,11 @@ class SettingsBillingState with _$SettingsBillingState {
required WorkspaceSubscriptionPB subscription,
required BillingPortalPB? billingPortal,
}) = _Ready;
@override
List<Object?> get props => maybeWhen(
orElse: () => const [],
error: (error) => [error],
ready: (subscription, billingPortal) => [subscription, billingPortal],
);
}

View File

@ -6,7 +6,6 @@ import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
@ -36,7 +35,6 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
final snapshots = await Future.wait([
_service.getWorkspaceUsage(),
UserBackendService.getWorkspaceSubscriptions(),
_service.getBillingPortal(),
]);
FlowyError? error;
@ -65,24 +63,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
},
);
final billingPortalResult = snapshots.last;
final billingPortal = billingPortalResult.fold(
(s) => s as BillingPortalPB,
(e) {
// Not a customer yet
if (e.code == ErrorCode.InvalidParams) {
return BillingPortalPB();
}
error = e;
return null;
},
);
if (usageResult == null ||
subscription == null ||
billingPortal == null ||
error != null) {
if (usageResult == null || subscription == null || error != null) {
return emit(SettingsPlanState.error(error: error));
}
@ -90,7 +71,6 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
billingPortal: billingPortal,
showSuccessDialog: withShowSuccessful,
),
);
@ -100,7 +80,6 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
billingPortal: billingPortal,
),
);
}
@ -176,7 +155,6 @@ class SettingsPlanState with _$SettingsPlanState {
const factory SettingsPlanState.ready({
required WorkspaceUsagePB workspaceUsage,
required WorkspaceSubscriptionPB subscription,
required BillingPortalPB? billingPortal,
@Default(false) bool showSuccessDialog,
@Default(false) bool downgradeProcessing,
}) = _Ready;

View File

@ -58,8 +58,7 @@ class SettingsBillingView extends StatelessWidget {
return ErrorWidget.withDetails(message: 'Something went wrong!');
},
ready: (state) {
final billingPortalEnabled = state.billingPortal != null &&
state.billingPortal!.url.isNotEmpty &&
final billingPortalEnabled =
state.subscription.subscriptionPlan !=
SubscriptionPlanPB.None;
@ -85,8 +84,11 @@ class SettingsBillingView extends StatelessWidget {
),
if (billingPortalEnabled)
SingleSettingAction(
onPressed: () =>
afLaunchUrlString(state.billingPortal!.url),
onPressed: () => context
.read<SettingsBillingBloc>()
.add(
const SettingsBillingEvent.openCustomerPortal(),
),
label: LocaleKeys
.settings_billingPage_plan_billingPeriod
.tr(),
@ -105,8 +107,11 @@ class SettingsBillingView extends StatelessWidget {
.tr(),
children: [
SingleSettingAction(
onPressed: () =>
afLaunchUrlString(state.billingPortal!.url),
onPressed: () => context
.read<SettingsBillingBloc>()
.add(
const SettingsBillingEvent.openCustomerPortal(),
),
label: LocaleKeys
.settings_billingPage_paymentDetails_methodLabel
.tr(),
@ -119,24 +124,34 @@ class SettingsBillingView extends StatelessWidget {
],
),
// TODO(Mathias): Implement the business logic for AI Add-ons
const SettingsCategory(
title: 'Add-ons',
SettingsCategory(
title: LocaleKeys.settings_billingPage_addons_title.tr(),
children: [
SingleSettingAction(
buttonType: SingleSettingsButtonType.highlight,
label: 'AppFlowy AI Max',
description:
"\$8 /user per month billed annually or \$10 billed monthly",
buttonLabel: 'Add AI Max',
label: LocaleKeys
.settings_billingPage_addons_aiMax_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiMax_description
.tr(),
buttonLabel: LocaleKeys
.settings_billingPage_addons_aiMax_buttonLabel
.tr(),
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
),
SingleSettingAction(
buttonType: SingleSettingsButtonType.highlight,
label: 'AppFlowy AI Offline',
description:
"\$8 /user per month billed annually or \$10 billed monthly",
buttonLabel: 'Add AI Offline',
label: LocaleKeys
.settings_billingPage_addons_aiOnDevice_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiOnDevice_description
.tr(),
buttonLabel: LocaleKeys
.settings_billingPage_addons_aiOnDevice_buttonLabel
.tr(),
fontWeight: FontWeight.w500,
minWidth: _buttonsMinWidth,
),

View File

@ -57,16 +57,6 @@ class _SettingsPlanComparisonDialogState
if (readyState.showSuccessDialog) {
SettingsAlertDialog(
icon: Center(
child: SizedBox(
height: 90,
width: 90,
child: FlowySvg(
FlowySvgs.check_circle_s,
color: AFThemeExtension.of(context).success,
),
),
),
title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title
.tr(args: [readyState.subscription.label]),
subtitle: LocaleKeys
@ -588,21 +578,28 @@ class _Heading extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: 175,
width: 185,
height: height,
child: Padding(
padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
title,
fontSize: 24,
color: isPrimary
? AFThemeExtension.of(context).strongText
: Theme.of(context).isLightMode
? const Color(0xFF5C3699)
: const Color(0xFFC49BEC),
Row(
children: [
Expanded(
child: FlowyText.semibold(
title,
fontSize: 24,
overflow: TextOverflow.ellipsis,
color: isPrimary
? AFThemeExtension.of(context).strongText
: Theme.of(context).isLightMode
? const Color(0xFF5C3699)
: const Color(0xFFC49BEC),
),
),
],
),
if (description != null && description!.isNotEmpty) ...[
const VSpace(4),
@ -636,24 +633,20 @@ final _planLabels = [
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemThree.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipThree.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFour.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipFour.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFive.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(),
),
_PlanItem(
label: LocaleKeys.settings_comparePlanDialog_planLabels_itemEight.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipEight.tr(),
tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSeven.tr(),
),
];
@ -676,20 +669,17 @@ final List<_CellItem> _freeLabels = [
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_freeLabels_itemEight.tr(),
),
];
@ -705,19 +695,17 @@ final List<_CellItem> _proLabels = [
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(),
icon: FlowySvgs.check_m,
),
_CellItem(
LocaleKeys.settings_comparePlanDialog_proLabels_itemEight.tr(),
),
];

View File

@ -69,7 +69,6 @@ class SettingsPlanView extends StatelessWidget {
_PlanUsageSummary(
usage: state.workspaceUsage,
subscription: state.subscription,
billingPortal: state.billingPortal,
),
const VSpace(16),
_CurrentPlanBox(subscription: state.subscription),
@ -78,7 +77,7 @@ class SettingsPlanView extends StatelessWidget {
FlowyText(
'Add-ons',
fontSize: 18,
color: AFThemeExtension.of(context).secondaryTextColor,
color: AFThemeExtension.of(context).strongText,
fontWeight: FontWeight.w600,
),
const VSpace(8),
@ -88,8 +87,8 @@ class SettingsPlanView extends StatelessWidget {
child: _AddOnBox(
title: "AI Max",
description:
"Unlimited AI responses with access to the latest advanced AI models.",
price: "\$8",
"Unlimited AI models and access to advanced models",
price: "US\$8",
priceInfo: "billed annually or \$10 billed monthly",
buttonText: "Add AI Max",
),
@ -99,8 +98,8 @@ class SettingsPlanView extends StatelessWidget {
child: _AddOnBox(
title: "AI Offline",
description:
"Run AI locally on your device for maximum privacy.",
price: "\$8",
"Local AI on your own hardware for ultimate privacy",
price: "US\$8",
priceInfo: "billed annually or \$10 billed monthly",
buttonText: "Add AI Offline",
),
@ -247,8 +246,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> {
final appearance = context.read<AppearanceSettingsCubit>().state;
return appearance.dateFormat.formatDate(
widget.subscription.canceledAt.toDateTime(),
true,
appearance.timeFormat,
false,
);
}
@ -273,12 +271,10 @@ class _PlanUsageSummary extends StatelessWidget {
const _PlanUsageSummary({
required this.usage,
required this.subscription,
this.billingPortal,
});
final WorkspaceUsagePB usage;
final WorkspaceSubscriptionPB subscription;
final BillingPortalPB? billingPortal;
@override
Widget build(BuildContext context) {
@ -342,19 +338,17 @@ class _PlanUsageSummary extends StatelessWidget {
subscription: subscription,
badgeLabel:
LocaleKeys.settings_planPage_planUsage_proBadge.tr(),
onTap: billingPortal?.url == null
? null
: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.Pro,
),
);
await Future.delayed(
const Duration(seconds: 2),
() {},
);
},
onTap: () async {
context.read<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(
SubscriptionPlanPB.Pro,
),
);
await Future.delayed(
const Duration(seconds: 2),
() {},
);
},
),
],
],
@ -553,7 +547,7 @@ class _AddOnBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 200,
height: 220,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@ -571,7 +565,6 @@ class _AddOnBox extends StatelessWidget {
color: AFThemeExtension.of(context).secondaryTextColor,
),
const VSpace(4),
const VSpace(4),
FlowyText.regular(
description,
fontSize: 11,
@ -579,24 +572,17 @@ class _AddOnBox extends StatelessWidget {
maxLines: 4,
),
const VSpace(4),
Row(
children: [
FlowyText(
price,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
const HSpace(4),
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText(
'/user per month',
fontSize: 11,
color: AFThemeExtension.of(context).strongText,
),
),
],
FlowyText(
price,
fontSize: 24,
color: AFThemeExtension.of(context).strongText,
),
FlowyText(
'/user per month',
fontSize: 11,
color: AFThemeExtension.of(context).strongText,
),
const VSpace(6),
Row(
children: [
Expanded(
@ -617,6 +603,7 @@ class _AddOnBox extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor: Colors.transparent,
constraints: const BoxConstraints(minWidth: 115),
radius: Corners.s16Border,
hoverColor: const Color(0xFF5C3699),
fontColor: const Color(0xFF5C3699),

View File

@ -631,13 +631,13 @@
"title": "Plan usage summary",
"storageLabel": "Storage",
"storageUsage": "{} of {} GB",
"collaboratorsLabel": "Collaborators",
"collaboratorsLabel": "Members",
"collaboratorsUsage": "{} of {}",
"aiResponseLabel": "AI Responses",
"aiResponseUsage": "{} of {}",
"proBadge": "Pro",
"memberProToggle": "10 members and unlimited AI responses",
"storageUnlimited": "Unlimited storage with your Pro Plan",
"memberProToggle": "More members & unlimited AI",
"storageUnlimited": "Unlimited storage with Pro Plan",
"aiCredit": {
"title": "Add @:appName AI Credit",
"price": "5$",
@ -655,7 +655,7 @@
"freeInfo": "Perfect for individuals or small teams up to 3 members.",
"proInfo": "Perfect for small and medium teams up to 10 members.",
"teamInfo": "Perfect for all productive and well-organized teams..",
"upgrade": "Upgrade plan",
"upgrade": "Change plan",
"canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}."
},
"deal": {
@ -681,6 +681,19 @@
"title": "Payment details",
"methodLabel": "Payment method",
"methodButtonLabel": "Edit method"
},
"addons": {
"title": "Add-ons",
"aiMax": {
"label": "AI Max",
"description": "US$8 /user per month billed annually or US$10 billed monthly",
"buttonLabel": "Add AI Max"
},
"aiOnDevice": {
"label": "AI On-device",
"description": "US$8 /user per month billed annually or US$10 billed monthly",
"buttonLabel": "Add AI On-device"
}
}
},
"comparePlanDialog": {
@ -695,48 +708,44 @@
},
"freePlan": {
"title": "Free",
"description": "For organizing every corner of your work & life.",
"price": "$0",
"description": "For individuals and small groups to organize everything",
"price": "US$0",
"priceInfo": "free forever"
},
"proPlan": {
"title": "Professional",
"description": "A place for small groups to plan & get organized.",
"price": "$10 /month",
"priceInfo": "billed annually"
"description": "For small teams to manage projects and team knowledge",
"price": "US$12.5",
"priceInfo": "billed monthly"
},
"planLabels": {
"itemOne": "Workspaces",
"itemTwo": "Members",
"itemThree": "Guests",
"tooltipThree": "Guests have read-only permission to the specifically shared content",
"itemFour": "Guest collaborators",
"tooltipFour": "Guest collaborators are billed as one seat",
"itemFive": "Storage",
"itemSix": "Real-time collaboration",
"itemSeven": "Mobile app",
"itemEight": "AI Responses",
"tooltipEight": "Lifetime means the number of responses never reset"
"itemThree": "Storage",
"itemFour": "Real-time collaboration",
"itemFive": "Mobile app",
"itemSix": "AI Responses",
"tooltipSix": "Lifetime means the number of responses never reset",
"itemSeven": "Custom namespace",
"tooltipSeven": "Allows you to customize part of the URL for your workspace"
},
"freeLabels": {
"itemOne": "charged per workspace",
"itemTwo": "3",
"itemThree": "",
"itemFour": "0",
"itemFive": "5 GB",
"itemSix": "yes",
"itemSeven": "yes",
"itemEight": "1,000 lifetime"
"itemTwo": "up to 3",
"itemThree": "5 GB",
"itemFour": "yes",
"itemFive": "yes",
"itemSix": "100 lifetime",
"itemSeven": ""
},
"proLabels": {
"itemOne": "charged per workspace",
"itemTwo": "up to 10",
"itemThree": "",
"itemFour": "10 guests billed as one seat",
"itemFive": "unlimited",
"itemSix": "yes",
"itemSeven": "yes",
"itemEight": "10,000 monthly"
"itemThree": "unlimited",
"itemFour": "yes",
"itemFive": "yes",
"itemSix": "unlimited",
"itemSeven": ""
},
"paymentSuccess": {
"title": "You are now on the {} plan!",

View File

@ -19,9 +19,9 @@ use flowy_server_pub::af_cloud_config::AFCloudConfiguration;
use flowy_server_pub::AuthenticatorType;
use flowy_user::entities::{
AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB,
OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB,
SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB,
UserWorkspaceIdPB, UserWorkspacePB,
OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, RepeatedWorkspaceSubscriptionPB,
SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB,
UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB,
};
use flowy_user::errors::{FlowyError, FlowyResult};
use flowy_user::event_map::UserEvent;
@ -315,6 +315,14 @@ impl EventIntegrationTest {
.await;
}
pub async fn get_workspace_subscriptions(&self) -> RepeatedWorkspaceSubscriptionPB {
EventBuilder::new(self.clone())
.event(UserEvent::GetWorkspaceSubscriptions)
.async_send()
.await
.parse::<RepeatedWorkspaceSubscriptionPB>()
}
pub async fn leave_workspace(&self, workspace_id: &str) {
let payload = UserWorkspaceIdPB {
workspace_id: workspace_id.to_string(),

View File

@ -240,3 +240,18 @@ async fn af_cloud_different_open_same_workspace_test() {
assert_eq!(views.len(), 1, "only get: {:?}", views); // Expecting two views.
assert_eq!(views[0].name, "Getting started");
}
#[tokio::test]
async fn af_cloud_get_workspace_subscriptions_test() {
user_localhost_af_cloud().await;
let test = EventIntegrationTest::new().await;
let workspaces = test.get_all_workspaces().await.items;
let first_workspace_id = workspaces[0].workspace_id.as_str();
assert_eq!(workspaces.len(), 1);
let subscriptions = test.get_workspace_subscriptions().await;
assert_eq!(subscriptions.items.len(), 1);
assert_eq!(subscriptions.items[0].workspace_id, first_workspace_id);
}

View File

@ -739,6 +739,7 @@ fn to_workspace_subscription(s: WorkspaceSubscriptionStatus) -> WorkspaceSubscri
},
},
is_active: matches!(s.subscription_status, SubscriptionStatus::Active),
has_canceled: s.canceled_at.is_some(),
canceled_at: s.canceled_at,
}
}

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
drop table workspace_subscriptions_table;

View File

@ -0,0 +1,11 @@
-- Your SQL goes here
CREATE TABLE workspace_subscriptions_table (
workspace_id TEXT NOT NULL,
subscription_plan INTEGER NOT NULL,
recurring_interval INTEGER NOT NULL,
is_active BOOLEAN NOT NULL,
has_canceled BOOLEAN NOT NULL DEFAULT FALSE,
canceled_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (workspace_id)
);

View File

@ -114,6 +114,18 @@ diesel::table! {
}
}
diesel::table! {
workspace_subscriptions_table (workspace_id) {
workspace_id -> Text,
subscription_plan -> BigInt,
recurring_interval -> BigInt,
is_active -> Bool,
has_canceled -> Bool,
canceled_at -> Nullable<BigInt>,
updated_at -> Timestamp,
}
}
diesel::allow_tables_to_appear_in_same_query!(
chat_local_setting_table,
chat_message_table,
@ -125,4 +137,5 @@ diesel::allow_tables_to_appear_in_same_query!(
user_table,
user_workspace_table,
workspace_members_table,
workspace_subscriptions_table,
);

View File

@ -453,22 +453,65 @@ pub struct WorkspaceInvitation {
pub updated_at: DateTime<Utc>,
}
#[derive(Clone, Debug)]
pub enum RecurringInterval {
Month,
Year,
}
impl Into<i64> for RecurringInterval {
fn into(self) -> i64 {
match self {
RecurringInterval::Month => 0,
RecurringInterval::Year => 1,
}
}
}
impl From<i64> for RecurringInterval {
fn from(value: i64) -> Self {
match value {
0 => RecurringInterval::Month,
1 => RecurringInterval::Year,
_ => RecurringInterval::Month,
}
}
}
#[derive(Clone, Debug)]
pub enum SubscriptionPlan {
None,
Pro,
Team,
}
impl Into<i64> for SubscriptionPlan {
fn into(self) -> i64 {
match self {
SubscriptionPlan::None => 0,
SubscriptionPlan::Pro => 1,
SubscriptionPlan::Team => 2,
}
}
}
impl From<i64> for SubscriptionPlan {
fn from(value: i64) -> Self {
match value {
0 => SubscriptionPlan::None,
1 => SubscriptionPlan::Pro,
2 => SubscriptionPlan::Team,
_ => SubscriptionPlan::None,
}
}
}
pub struct WorkspaceSubscription {
pub workspace_id: String,
pub subscription_plan: SubscriptionPlan,
pub recurring_interval: RecurringInterval,
pub is_active: bool,
pub has_canceled: bool,
pub canceled_at: Option<i64>,
}

View File

@ -1,10 +1,14 @@
use chrono::{TimeZone, Utc};
use diesel::insert_into;
use diesel::{RunQueryDsl, SqliteConnection};
use flowy_error::FlowyError;
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite::schema::user_workspace_table;
use flowy_sqlite::schema::workspace_subscriptions_table;
use flowy_sqlite::schema::workspace_subscriptions_table::dsl;
use flowy_sqlite::DBConnection;
use flowy_sqlite::{query_dsl::*, ExpressionMethods};
use flowy_user_pub::entities::UserWorkspace;
use flowy_user_pub::entities::WorkspaceSubscription;
use std::convert::TryFrom;
#[derive(Clone, Default, Queryable, Identifiable, Insertable)]
@ -18,6 +22,19 @@ pub struct UserWorkspaceTable {
pub icon: String,
}
#[derive(Queryable, Insertable, AsChangeset, Debug)]
#[diesel(table_name = workspace_subscriptions_table)]
#[diesel(primary_key(workspace_id))]
pub struct WorkspaceSubscriptionsTable {
pub workspace_id: String,
pub subscription_plan: i64,
pub recurring_interval: i64,
pub is_active: bool,
pub has_canceled: bool,
pub canceled_at: Option<i64>,
pub updated_at: chrono::NaiveDateTime,
}
pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Option<UserWorkspace> {
user_workspace_table::dsl::user_workspace_table
.filter(user_workspace_table::id.eq(workspace_id))
@ -74,6 +91,46 @@ pub fn insert_new_workspaces_op(
Ok(())
}
pub fn select_workspace_subscription(
mut conn: DBConnection,
workspace_id: &str,
) -> FlowyResult<WorkspaceSubscriptionsTable> {
let subscription = dsl::workspace_subscriptions_table
.filter(workspace_subscriptions_table::workspace_id.eq(workspace_id))
.first::<WorkspaceSubscriptionsTable>(&mut conn)?;
Ok(subscription)
}
pub fn upsert_workspace_subscription<T: Into<WorkspaceSubscriptionsTable>>(
mut conn: DBConnection,
subscription: T,
) -> FlowyResult<()> {
let subscription = subscription.into();
insert_into(workspace_subscriptions_table::table)
.values(&subscription)
.on_conflict((workspace_subscriptions_table::workspace_id,))
.do_update()
.set(&subscription)
.execute(&mut conn)?;
Ok(())
}
impl From<WorkspaceSubscriptionsTable> for WorkspaceSubscription {
fn from(value: WorkspaceSubscriptionsTable) -> Self {
Self {
workspace_id: value.workspace_id,
subscription_plan: value.subscription_plan.into(),
recurring_interval: value.recurring_interval.into(),
is_active: value.is_active,
has_canceled: value.has_canceled,
canceled_at: value.canceled_at,
}
}
}
impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable {
type Error = FlowyError;

View File

@ -11,8 +11,9 @@ use flowy_folder_pub::entities::{AppFlowyData, ImportData};
use flowy_sqlite::schema::user_workspace_table;
use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods};
use flowy_user_pub::entities::{
Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus,
WorkspaceMember, WorkspaceSubscription, WorkspaceUsage,
RecurringInterval, Role, SubscriptionPlan, UpdateUserProfileParams, UserWorkspace,
WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, WorkspaceSubscription,
WorkspaceUsage,
};
use lib_dispatch::prelude::af_spawn;
@ -30,7 +31,9 @@ use crate::services::sqlite_sql::member_sql::{
};
use crate::services::sqlite_sql::user_sql::UserTableChangeset;
use crate::services::sqlite_sql::workspace_sql::{
get_all_user_workspace_op, get_user_workspace_op, insert_new_workspaces_op, UserWorkspaceTable,
get_all_user_workspace_op, get_user_workspace_op, insert_new_workspaces_op,
select_workspace_subscription, upsert_workspace_subscription, UserWorkspaceTable,
WorkspaceSubscriptionsTable,
};
use crate::user_manager::{upsert_user_profile_change, UserManager};
use flowy_user_pub::session::Session;
@ -447,12 +450,62 @@ impl UserManager {
#[instrument(level = "info", skip(self), err)]
pub async fn get_workspace_subscriptions(&self) -> FlowyResult<Vec<WorkspaceSubscription>> {
let res = self
let session = self.get_session()?;
let uid = session.user_id;
let workspace_id = session.user_workspace.id.clone();
let db = self.authenticate_user.get_sqlite_connection(uid)?;
// We check if we can use the cache from local sqlite db
if let Ok(subscription) = select_workspace_subscription(db, &workspace_id) {
if is_older_than_n_minutes(subscription.updated_at, 10) {
self.get_workspace_subscriptions_from_remote(uid).await?;
}
return Ok(vec![WorkspaceSubscription {
workspace_id: subscription.workspace_id,
subscription_plan: subscription.subscription_plan.into(),
recurring_interval: subscription.recurring_interval.into(),
is_active: subscription.is_active,
has_canceled: subscription.has_canceled,
canceled_at: subscription.canceled_at,
}]);
}
let subscriptions = self.get_workspace_subscriptions_from_remote(uid).await?;
Ok(subscriptions)
}
async fn get_workspace_subscriptions_from_remote(
&self,
uid: i64,
) -> FlowyResult<Vec<WorkspaceSubscription>> {
let subscriptions = self
.cloud_services
.get_user_service()?
.get_workspace_subscriptions()
.await?;
Ok(res)
for subscription in &subscriptions {
let db = self.authenticate_user.get_sqlite_connection(uid)?;
let record = WorkspaceSubscriptionsTable {
workspace_id: subscription.workspace_id.clone(),
subscription_plan: <SubscriptionPlan as Into<i64>>::into(
subscription.subscription_plan.clone(),
),
recurring_interval: <RecurringInterval as Into<i64>>::into(
subscription.recurring_interval.clone(),
),
is_active: subscription.is_active,
has_canceled: subscription.has_canceled,
canceled_at: subscription.canceled_at.clone().into(),
updated_at: Utc::now().naive_utc(),
};
upsert_workspace_subscription(db, record)?;
}
Ok(subscriptions)
}
#[instrument(level = "info", skip(self), err)]