Feature Policies API + Invitations Policies (#375)
- Added Feature Policy API: a declarative system to enable/disable/modify default behavior in the SaaS kit - Team invitation policies with pre-checks using the Feature Policy API: Invite Members dialog now shows loading, errors, and clear reasons when invitations are blocked - Version bump to 2.16.0 and widespread dependency updates (Supabase, React types, react-i18next, etc.). - Added comprehensive docs for the new policy system and orchestrators. - Subscription cancellations now trigger immediate invoicing explicitly
This commit is contained in:
committed by
GitHub
parent
3c13b5ec1e
commit
1dd6fdad22
@@ -6,14 +6,18 @@ import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
|
||||
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
|
||||
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
import { createInvitationContextBuilder } from '../policies/invitation-context-builder';
|
||||
import { createInvitationsPolicyEvaluator } from '../policies/invitation-policies';
|
||||
import { createAccountInvitationsService } from '../services/account-invitations.service';
|
||||
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
||||
|
||||
@@ -22,20 +26,47 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat
|
||||
* @description Creates invitations for inviting members.
|
||||
*/
|
||||
export const createInvitationsAction = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
async (params, user) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
// Create the service
|
||||
logger.info(
|
||||
{ params, userId: user.id },
|
||||
'User requested to send invitations',
|
||||
);
|
||||
|
||||
// Evaluate invitation policies
|
||||
const policiesResult = await evaluateInvitationsPolicies(params, user);
|
||||
|
||||
// If the invitations are not allowed, throw an error
|
||||
if (!policiesResult.allowed) {
|
||||
logger.info(
|
||||
{ reasons: policiesResult?.reasons, userId: user.id },
|
||||
'Invitations blocked by policies',
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
reasons: policiesResult?.reasons,
|
||||
};
|
||||
}
|
||||
|
||||
// invitations are allowed, so continue with the action
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// send invitations
|
||||
await service.sendInvitations(params);
|
||||
try {
|
||||
await service.sendInvitations(params);
|
||||
|
||||
revalidateMemberPage();
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
schema: InviteMembersSchema.and(
|
||||
@@ -157,3 +188,30 @@ export const renewInvitationAction = enhanceAction(
|
||||
function revalidateMemberPage() {
|
||||
revalidatePath('/home/[account]/members', 'page');
|
||||
}
|
||||
|
||||
/**
|
||||
* @name evaluateInvitationsPolicies
|
||||
* @description Evaluates invitation policies with performance optimization.
|
||||
* @param params - The invitations to evaluate (emails and roles).
|
||||
*/
|
||||
async function evaluateInvitationsPolicies(
|
||||
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||
user: JWTUserData,
|
||||
) {
|
||||
const evaluator = createInvitationsPolicyEvaluator();
|
||||
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
|
||||
|
||||
// No policies to evaluate, skip
|
||||
if (!hasPolicies) {
|
||||
return {
|
||||
allowed: true,
|
||||
reasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const builder = createInvitationContextBuilder(client);
|
||||
const context = await builder.buildContext(params, user);
|
||||
|
||||
return evaluator.canInvite(context, 'submission');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PolicyContext, PolicyResult } from '@kit/policies';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* Invitation policy context that extends the base PolicyContext
|
||||
* from @kit/policies for invitation-specific data.
|
||||
*/
|
||||
export interface FeaturePolicyInvitationContext extends PolicyContext {
|
||||
/** The account slug being invited to */
|
||||
accountSlug: string;
|
||||
|
||||
/** The account ID being invited to (same as accountId from base) */
|
||||
accountId: string;
|
||||
|
||||
/** Current subscription data for the account */
|
||||
subscription?: {
|
||||
id: string;
|
||||
status: Database['public']['Enums']['subscription_status'];
|
||||
provider: Database['public']['Enums']['billing_provider'];
|
||||
active: boolean;
|
||||
trial_starts_at?: string;
|
||||
trial_ends_at?: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
type: Database['public']['Enums']['subscription_item_type'];
|
||||
quantity: number;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Current number of members in the account */
|
||||
currentMemberCount: number;
|
||||
|
||||
/** The invitations being attempted */
|
||||
invitations: Array<{
|
||||
email: string;
|
||||
role: string;
|
||||
}>;
|
||||
|
||||
/** The user performing the invitation */
|
||||
invitingUser: {
|
||||
id: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation policy result that extends the base PolicyResult
|
||||
* from @kit/policies while maintaining backward compatibility.
|
||||
*/
|
||||
export interface FeaturePolicyInvitationResult extends PolicyResult {
|
||||
/** Whether the invitations are allowed */
|
||||
allowed: boolean;
|
||||
|
||||
/** Human-readable reason if not allowed */
|
||||
reason?: string;
|
||||
|
||||
/** Additional metadata for logging/debugging */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createInvitationsPolicyEvaluator } from './invitation-policies';
|
||||
|
||||
// Context building
|
||||
export { createInvitationContextBuilder } from './invitation-context-builder';
|
||||
|
||||
// Type exports
|
||||
export type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
|
||||
/**
|
||||
* Creates an invitation context builder
|
||||
* @param client - The Supabase client
|
||||
* @returns
|
||||
*/
|
||||
export function createInvitationContextBuilder(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new InvitationContextBuilder(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation context builder
|
||||
*/
|
||||
class InvitationContextBuilder {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* Build policy context for invitation evaluation with optimized parallel loading
|
||||
*/
|
||||
async buildContext(
|
||||
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||
user: JWTUserData,
|
||||
): Promise<FeaturePolicyInvitationContext> {
|
||||
// Fetch all data in parallel for optimal performance
|
||||
const account = await this.getAccount(params.accountSlug);
|
||||
|
||||
// Fetch subscription and member count in parallel using account ID
|
||||
const [subscription, memberCount] = await Promise.all([
|
||||
this.getSubscription(account.id),
|
||||
this.getMemberCount(account.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
// Base PolicyContext fields
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
accountSlug: params.accountSlug,
|
||||
invitationCount: params.invitations.length,
|
||||
invitingUserEmail: user.email as string,
|
||||
},
|
||||
|
||||
// Invitation-specific fields
|
||||
accountSlug: params.accountSlug,
|
||||
accountId: account.id,
|
||||
subscription,
|
||||
currentMemberCount: memberCount,
|
||||
invitations: params.invitations,
|
||||
invitingUser: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the account from the database
|
||||
* @param accountSlug - The slug of the account to get
|
||||
* @returns
|
||||
*/
|
||||
private async getAccount(accountSlug: string) {
|
||||
const { data: account } = await this.client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', accountSlug)
|
||||
.single();
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription from the database
|
||||
* @param accountId - The ID of the account to get the subscription for
|
||||
* @returns
|
||||
*/
|
||||
private async getSubscription(accountId: string) {
|
||||
const { data: subscription } = await this.client
|
||||
.from('subscriptions')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
status,
|
||||
active,
|
||||
trial_starts_at,
|
||||
trial_ends_at,
|
||||
billing_provider,
|
||||
subscription_items(
|
||||
id,
|
||||
type,
|
||||
quantity,
|
||||
product_id,
|
||||
variant_id
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.eq('active', true)
|
||||
.single();
|
||||
|
||||
return subscription
|
||||
? {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
provider: subscription.billing_provider,
|
||||
active: subscription.active,
|
||||
trial_starts_at: subscription.trial_starts_at || undefined,
|
||||
trial_ends_at: subscription.trial_ends_at || undefined,
|
||||
items:
|
||||
subscription.subscription_items?.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
quantity: item.quantity,
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
})) || [],
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the member count from the database
|
||||
* @param accountId - The ID of the account to get the member count for
|
||||
* @returns
|
||||
*/
|
||||
private async getMemberCount(accountId: string) {
|
||||
const { count } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId);
|
||||
|
||||
return count || 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createPoliciesEvaluator } from '@kit/policies';
|
||||
|
||||
import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
import { invitationPolicyRegistry } from './policies';
|
||||
|
||||
/**
|
||||
* Creates an invitation evaluator
|
||||
*/
|
||||
export function createInvitationsPolicyEvaluator() {
|
||||
const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>();
|
||||
|
||||
return {
|
||||
/**
|
||||
* Checks if there are any invitation policies for the given stage
|
||||
* @param stage - The stage to check if there are any invitation policies for
|
||||
*/
|
||||
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
|
||||
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
|
||||
},
|
||||
|
||||
/**
|
||||
* Evaluates the invitation policies for the given context and stage
|
||||
* @param context - The context for the invitation policy
|
||||
* @param stage - The stage to evaluate the invitation policies for
|
||||
* @returns
|
||||
*/
|
||||
async canInvite(
|
||||
context: FeaturePolicyInvitationContext,
|
||||
stage: 'preliminary' | 'submission',
|
||||
) {
|
||||
return evaluator.evaluate(
|
||||
invitationPolicyRegistry,
|
||||
context,
|
||||
'ALL',
|
||||
stage,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { allow, definePolicy, deny } from '@kit/policies';
|
||||
import { createPolicyRegistry } from '@kit/policies';
|
||||
|
||||
import { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||
|
||||
/**
|
||||
* Feature-specific registry for invitation policies
|
||||
*/
|
||||
export const invitationPolicyRegistry = createPolicyRegistry();
|
||||
|
||||
/**
|
||||
* Subscription required policy
|
||||
* Checks if the account has an active subscription
|
||||
*/
|
||||
export const subscriptionRequiredInvitationsPolicy =
|
||||
definePolicy<FeaturePolicyInvitationContext>({
|
||||
id: 'subscription-required',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async ({ subscription }) => {
|
||||
if (!subscription || !subscription.active) {
|
||||
return deny({
|
||||
code: 'SUBSCRIPTION_REQUIRED',
|
||||
message: 'teams:policyErrors.subscriptionRequired',
|
||||
remediation: 'teams:policyRemediation.subscriptionRequired',
|
||||
});
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Paddle billing policy
|
||||
* Checks if the account has a paddle subscription and is in a trial period
|
||||
*/
|
||||
export const paddleBillingInvitationsPolicy =
|
||||
definePolicy<FeaturePolicyInvitationContext>({
|
||||
id: 'paddle-billing',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async ({ subscription }) => {
|
||||
// combine with subscriptionRequiredPolicy if subscription must be required
|
||||
if (!subscription) {
|
||||
return allow();
|
||||
}
|
||||
|
||||
// Paddle specific constraint: cannot update subscription items during trial
|
||||
if (
|
||||
subscription.provider === 'paddle' &&
|
||||
subscription.status === 'trialing'
|
||||
) {
|
||||
const hasPerSeatItems = subscription.items.some(
|
||||
(item) => item.type === 'per_seat',
|
||||
);
|
||||
|
||||
if (hasPerSeatItems) {
|
||||
return deny({
|
||||
code: 'PADDLE_TRIAL_RESTRICTION',
|
||||
message: 'teams:policyErrors.paddleTrialRestriction',
|
||||
remediation: 'teams:policyRemediation.paddleTrialRestriction',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
// register policies below to apply them
|
||||
//
|
||||
//
|
||||
@@ -12,6 +12,10 @@ import type { DeleteInvitationSchema } from '../../schema/delete-invitation.sche
|
||||
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
|
||||
/**
|
||||
*
|
||||
* Create an account invitations service.
|
||||
*/
|
||||
export function createAccountInvitationsService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
|
||||
@@ -44,7 +44,8 @@ class AccountPerSeatBillingService {
|
||||
subscription_items !inner (
|
||||
quantity,
|
||||
id,
|
||||
type
|
||||
type,
|
||||
variant_id
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user