refactor: replace createSafeActionClient with userActionClient for improved session handling across multiple actions

This commit is contained in:
javayhu 2025-08-16 23:00:21 +08:00
parent 262228d6e9
commit d1928575b3
17 changed files with 143 additions and 287 deletions

View File

@ -1,19 +1,16 @@
'use server';
import { userActionClient } from '@/lib/safe-action';
import { isSubscribed } from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
});
// Create a safe action to check if a user is subscribed to the newsletter
export const checkNewsletterStatusAction = actionClient
export const checkNewsletterStatusAction = userActionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
try {

View File

@ -2,29 +2,21 @@
import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config';
import { getUserCredits, hasEnoughCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient();
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
/**
* Check if user has enough credits for web content analysis
*/
export const checkWebContentAnalysisCreditsAction = actionClient.action(
async () => {
const session = await getSession();
if (!session) {
console.warn(
'unauthorized request to check web content analysis credits'
);
return { success: false, error: 'Unauthorized' };
}
export const checkWebContentAnalysisCreditsAction = userActionClient.action(
async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
try {
const requiredCredits = getWebContentAnalysisCost();
const currentCredits = await getUserCredits(session.user.id);
const currentCredits = await getUserCredits(currentUser.id);
const hasCredits = await hasEnoughCredits({
userId: session.user.id,
userId: currentUser.id,
requiredCredits,
});

View File

@ -1,12 +1,10 @@
'use server';
import { consumeCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { z } from 'zod';
const actionClient = createSafeActionClient();
// consume credits schema
const consumeSchema = z.object({
amount: z.number().min(1),
@ -16,21 +14,17 @@ const consumeSchema = z.object({
/**
* Consume credits
*/
export const consumeCreditsAction = actionClient
export const consumeCreditsAction = userActionClient
.schema(consumeSchema)
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to consume credits');
return { success: false, error: 'Unauthorized' };
}
.action(async ({ parsedInput, ctx }) => {
const { amount, description } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
await consumeCredits({
userId: session.user.id,
amount: parsedInput.amount,
description:
parsedInput.description || `Consume credits: ${parsedInput.amount}`,
userId: currentUser.id,
amount,
description: description || `Consume credits: ${amount}`,
});
return { success: true };
} catch (error) {

View File

@ -1,20 +1,17 @@
'use server';
import { websiteConfig } from '@/config/website';
import type { User } from '@/lib/auth-types';
import { findPlanByPlanId } from '@/lib/price-plan';
import { getSession } from '@/lib/server';
import { userActionClient } from '@/lib/safe-action';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCheckout } from '@/payment';
import type { CreateCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Checkout schema for validation
// metadata is optional, and may contain referral information if you need
const checkoutSchema = z.object({
@ -27,33 +24,11 @@ const checkoutSchema = z.object({
/**
* Create a checkout session for a price plan
*/
export const createCheckoutAction = actionClient
export const createCheckoutAction = userActionClient
.schema(checkoutSchema)
.action(async ({ parsedInput }) => {
const { userId, planId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ parsedInput, ctx }) => {
const { planId, priceId, metadata } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
// Get the current locale from the request
@ -71,8 +46,8 @@ export const createCheckoutAction = actionClient
// Add user id to metadata, so we can get it in the webhook event
const customMetadata: Record<string, string> = {
...metadata,
userId: session.user.id,
userName: session.user.name,
userId: currentUser.id,
userName: currentUser.name,
};
// https://datafa.st/docs/stripe-checkout-api
@ -94,7 +69,7 @@ export const createCheckoutAction = actionClient
const params: CreateCheckoutParams = {
planId,
priceId,
customerEmail: session.user.email,
customerEmail: currentUser.email,
metadata: customMetadata,
successUrl,
cancelUrl,

View File

@ -2,19 +2,16 @@
import { websiteConfig } from '@/config/website';
import { getCreditPackageById } from '@/credits/server';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCreditCheckout } from '@/payment';
import type { CreateCreditCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Credit checkout schema for validation
// metadata is optional, and may contain referral information if you need
const creditCheckoutSchema = z.object({
@ -27,33 +24,11 @@ const creditCheckoutSchema = z.object({
/**
* Create a checkout session for a credit package
*/
export const createCreditCheckoutSession = actionClient
export const createCreditCheckoutSession = userActionClient
.schema(creditCheckoutSchema)
.action(async ({ parsedInput }) => {
const { userId, packageId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ parsedInput, ctx }) => {
const { packageId, priceId, metadata } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
// Get the current locale from the request
@ -74,8 +49,8 @@ export const createCreditCheckoutSession = actionClient
type: 'credit_purchase',
packageId,
credits: creditPackage.credits.toString(),
userId: session.user.id,
userName: session.user.name,
userId: currentUser.id,
userName: currentUser.name,
};
// https://datafa.st/docs/stripe-checkout-api
@ -98,7 +73,7 @@ export const createCreditCheckoutSession = actionClient
const params: CreateCreditCheckoutParams = {
packageId,
priceId,
customerEmail: session.user.email,
customerEmail: currentUser.email,
metadata: customMetadata,
successUrl,
cancelUrl,

View File

@ -2,18 +2,15 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCustomerPortal } from '@/payment';
import type { CreatePortalParams } from '@/payment/types';
import { eq } from 'drizzle-orm';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Portal schema for validation
const portalSchema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
@ -26,33 +23,11 @@ const portalSchema = z.object({
/**
* Create a customer portal session
*/
export const createPortalAction = actionClient
export const createPortalAction = userActionClient
.schema(portalSchema)
.action(async ({ parsedInput }) => {
const { userId, returnUrl } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create portal session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own portal session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create portal session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ parsedInput, ctx }) => {
const { returnUrl } = parsedInput;
const currentUser = (ctx as { user: User }).user;
try {
// Get the user's customer ID from the database
@ -60,11 +35,11 @@ export const createPortalAction = actionClient
const customerResult = await db
.select({ customerId: user.customerId })
.from(user)
.where(eq(user.id, session.user.id))
.where(eq(user.id, currentUser.id))
.limit(1);
if (customerResult.length <= 0 || !customerResult[0].customerId) {
console.error(`No customer found for user ${session.user.id}`);
console.error(`No customer found for user ${currentUser.id}`);
return {
success: false,
error: 'No customer found for user',

View File

@ -1,13 +1,10 @@
'use server';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { getSubscriptions } from '@/payment';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Input schema
const schema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
@ -19,33 +16,10 @@ const schema = z.object({
* If the user has multiple subscriptions,
* it returns the most recent active or trialing one
*/
export const getActiveSubscriptionAction = actionClient
export const getActiveSubscriptionAction = userActionClient
.schema(schema)
.action(async ({ parsedInput }) => {
const { userId } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to get active subscription for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to check their own status unless they're admins
if (session.user.id !== userId && session.user.role !== 'admin') {
console.warn(
`current user ${session.user.id} is not authorized to get active subscription for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
// Check if Stripe environment variables are configured
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
@ -62,7 +36,7 @@ export const getActiveSubscriptionAction = actionClient
try {
// Find the user's most recent active subscription
const subscriptions = await getSubscriptions({
userId: session.user.id,
userId: currentUser.id,
});
// console.log('get user subscriptions:', subscriptions);
@ -76,16 +50,16 @@ export const getActiveSubscriptionAction = actionClient
// If found, use it
if (activeSubscription) {
console.log('find active subscription for userId:', session.user.id);
console.log('find active subscription for userId:', currentUser.id);
subscriptionData = activeSubscription;
} else {
console.log(
'no active subscription found for userId:',
session.user.id
currentUser.id
);
}
} else {
console.log('no subscriptions found for userId:', session.user.id);
console.log('no subscriptions found for userId:', currentUser.id);
}
return {

View File

@ -1,21 +1,16 @@
'use server';
import { getUserCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient();
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
/**
* Get current user's credits
*/
export const getCreditBalanceAction = actionClient.action(async () => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit balance');
return { success: false, error: 'Unauthorized' };
export const getCreditBalanceAction = userActionClient.action(
async ({ ctx }) => {
const user = (ctx as { user: User }).user;
const credits = await getUserCredits(user.id);
return { success: true, credits };
}
const credits = await getUserCredits(session.user.id);
return { success: true, credits };
});
);

View File

@ -3,34 +3,23 @@
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { addDays } from 'date-fns';
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
const CREDITS_EXPIRATION_DAYS = 31;
const CREDITS_MONTHLY_DAYS = 31;
// Create a safe action client
const actionClient = createSafeActionClient();
/**
* Get credit statistics for a user
*/
export const getCreditStatsAction = actionClient.action(async () => {
export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
try {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit stats');
return {
success: false,
error: 'Unauthorized',
};
}
const currentUser = (ctx as { user: User }).user;
const userId = currentUser.id;
const db = await getDb();
const userId = session.user.id;
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
const expiringCredits = await db

View File

@ -2,14 +2,11 @@
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import { getSession } from '@/lib/server';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getCreditTransactions parameters
const getCreditTransactionsSchema = z.object({
pageIndex: z.number().min(0).default(0),
@ -40,23 +37,17 @@ const sortFieldMap = {
} as const;
// Create a safe action for getting credit transactions
export const getCreditTransactionsAction = actionClient
export const getCreditTransactionsAction = userActionClient
.schema(getCreditTransactionsSchema)
.action(async ({ parsedInput }) => {
.action(async ({ parsedInput, ctx }) => {
try {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
const { pageIndex, pageSize, search, sorting } = parsedInput;
const currentUser = (ctx as { user: User }).user;
// search by type, amount, paymentId, description, and restrict to current user
const where = search
? and(
eq(creditTransaction.userId, session.user.id),
eq(creditTransaction.userId, currentUser.id),
or(
ilike(creditTransaction.type, `%${search}%`),
ilike(creditTransaction.amount, `%${search}%`),
@ -65,7 +56,7 @@ export const getCreditTransactionsAction = actionClient
ilike(creditTransaction.description, `%${search}%`)
)
)
: eq(creditTransaction.userId, session.user.id);
: eq(creditTransaction.userId, currentUser.id);
const offset = pageIndex * pageSize;

View File

@ -2,16 +2,13 @@
import { getDb } from '@/db';
import { payment } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { getSession } from '@/lib/server';
import { userActionClient } from '@/lib/safe-action';
import { PaymentTypes } from '@/payment/types';
import { and, eq } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Input schema
const schema = z.object({
userId: z.string().min(1, { error: 'User ID is required' }),
@ -25,33 +22,11 @@ const schema = z.object({
* in order to do this, you have to update the logic to check the lifetime status,
* for example, just check the planId is `lifetime` or not.
*/
export const getLifetimeStatusAction = actionClient
export const getLifetimeStatusAction = userActionClient
.schema(schema)
.action(async ({ parsedInput }) => {
const { userId } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to get lifetime status for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to check their own status unless they're admins
if (session.user.id !== userId && session.user.role !== 'admin') {
console.warn(
`current user ${session.user.id} is not authorized to get lifetime status for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
.action(async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
const userId = currentUser.id;
try {
// Get lifetime plans

View File

@ -3,14 +3,10 @@
import { getDb } from '@/db';
import { user } from '@/db/schema';
import { isDemoWebsite } from '@/lib/demo';
import { getSession } from '@/lib/server';
import { adminActionClient } from '@/lib/safe-action';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getUsers parameters
const getUsersSchema = z.object({
pageIndex: z.number().min(0).default(0),
@ -40,17 +36,9 @@ const sortFieldMap = {
} as const;
// Create a safe action for getting users
export const getUsersAction = actionClient
export const getUsersAction = adminActionClient
.schema(getUsersSchema)
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session || session.user.role !== 'admin') {
return {
success: false,
error: 'Unauthorized',
};
}
try {
const { pageIndex, pageSize, search, sorting } = parsedInput;

View File

@ -1,14 +1,11 @@
'use server';
import { websiteConfig } from '@/config/website';
import { actionClient } from '@/lib/safe-action';
import { sendEmail } from '@/mail';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
/**
* DOC: When using Zod for validation, how can I localize error messages?
* https://next-intl.dev/docs/environments/actions-metadata-route-handlers#server-actions

View File

@ -1,14 +1,11 @@
'use server';
import { actionClient } from '@/lib/safe-action';
import { sendEmail } from '@/mail';
import { subscribe } from '@/newsletter';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),

View File

@ -1,30 +1,18 @@
'use server';
import { getSession } from '@/lib/server';
import { userActionClient } from '@/lib/safe-action';
import { unsubscribe } from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Newsletter schema for validation
const newsletterSchema = z.object({
email: z.email({ error: 'Please enter a valid email address' }),
});
// Create a safe action for newsletter unsubscription
export const unsubscribeNewsletterAction = actionClient
export const unsubscribeNewsletterAction = userActionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
try {
const unsubscribed = await unsubscribe(email);

View File

@ -1,12 +1,9 @@
'use server';
import { validateTurnstileToken } from '@/lib/captcha';
import { createSafeActionClient } from 'next-safe-action';
import { actionClient } from '@/lib/safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Captcha validation schema
const captchaSchema = z.object({
captchaToken: z.string().min(1, { error: 'Captcha token is required' }),

57
src/lib/safe-action.ts Normal file
View File

@ -0,0 +1,57 @@
import { createSafeActionClient } from 'next-safe-action';
import type { User } from './auth-types';
import { isDemoWebsite } from './demo';
import { getSession } from './server';
// -----------------------------------------------------------------------------
// 1. Base action client put global error handling / metadata here if needed
// -----------------------------------------------------------------------------
export const actionClient = createSafeActionClient({
handleServerError: (e) => {
if (e instanceof Error) {
return {
success: false,
error: e.message,
};
}
return {
success: false,
error: 'Something went wrong while executing the action',
};
},
});
// -----------------------------------------------------------------------------
// 2. Auth-guarded client
// -----------------------------------------------------------------------------
export const userActionClient = actionClient.use(async ({ next }) => {
const session = await getSession();
if (!session?.user) {
return {
success: false,
error: 'Unauthorized',
};
}
return next({ ctx: { user: session.user } });
});
// -----------------------------------------------------------------------------
// 3. Admin-only client (extends auth client)
// -----------------------------------------------------------------------------
export const adminActionClient = userActionClient.use(async ({ next, ctx }) => {
const user = (ctx as { user: User }).user;
const isDemo = isDemoWebsite();
const isAdmin = user.role === 'admin';
// If this is a demo website and user is not an admin, allow the request
if (!isAdmin && !isDemo) {
return {
success: false,
error: 'Unauthorized',
};
}
return next({ ctx });
});