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:
Giancarlo Buomprisco
2025-09-30 12:36:19 +08:00
committed by GitHub
parent 3c13b5ec1e
commit 1dd6fdad22
53 changed files with 3908 additions and 1128 deletions

View File

@@ -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');
}

View File

@@ -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>;
}

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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,
);
},
};
}

View File

@@ -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
//
//

View File

@@ -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>,
) {

View File

@@ -44,7 +44,8 @@ class AccountPerSeatBillingService {
subscription_items !inner (
quantity,
id,
type
type,
variant_id
)
`,
)