Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actions/generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe('generateVariations', () => {

expect(mockCallAI).toHaveBeenCalledTimes(1);
const callArgs = mockCallAI.mock.calls[0][0];
expect(callArgs.system).toContain('social media post');
expect(callArgs.system).toContain('Generate exactly');
expect(callArgs.messages[0].content).toBe(validInput.input);
});

Expand Down
170 changes: 170 additions & 0 deletions actions/trial.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const { mockGetUser, mockFrom, mockRevalidatePath, mockServiceFrom } = vi.hoisted(() => ({
mockGetUser: vi.fn(),
mockFrom: vi.fn(),
mockRevalidatePath: vi.fn(),
mockServiceFrom: vi.fn(),
}));

vi.mock('@/lib/supabase/server', () => ({
createServerClient: vi.fn().mockResolvedValue({
auth: { getUser: mockGetUser },
from: mockFrom,
}),
createServiceRoleClient: vi.fn().mockReturnValue({
from: mockServiceFrom,
}),
}));

vi.mock('next/cache', () => ({
revalidatePath: mockRevalidatePath,
}));

import { activateFreeTrial } from './trial';

const MOCK_USER = {
id: 'user-123',
user_metadata: { full_name: 'Test User' },
};

function chainSubscriptions(data: { id?: string } | null): ReturnType<typeof vi.fn> {
const single = vi.fn().mockResolvedValue({ data });
const in_ = vi.fn().mockReturnValue({ single });
const eq = vi.fn().mockReturnValue({ in: in_ });
const select = vi.fn().mockReturnValue({ eq });
return { select, eq, in: in_, single };

Check failure on line 36 in actions/trial.test.ts

View workflow job for this annotation

GitHub Actions / CI Checks / Code Quality

Object literal may only specify known properties, and 'select' does not exist in type 'Mock<Procedure | Constructable>'.

Check failure on line 36 in actions/trial.test.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Object literal may only specify known properties, and 'select' does not exist in type 'Mock<Procedure | Constructable>'.
}

function chainProfilesSelect(data: { trial_uses_remaining: number | null }, error: { code?: string; message?: string } | null = null): ReturnType<typeof vi.fn> {
const single = vi.fn().mockResolvedValue({ data, error });
const eq = vi.fn().mockReturnValue({ single });
const select = vi.fn().mockReturnValue({ eq });
return { select, eq, single };

Check failure on line 43 in actions/trial.test.ts

View workflow job for this annotation

GitHub Actions / CI Checks / Code Quality

Object literal may only specify known properties, and 'select' does not exist in type 'Mock<Procedure | Constructable>'.

Check failure on line 43 in actions/trial.test.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Object literal may only specify known properties, and 'select' does not exist in type 'Mock<Procedure | Constructable>'.
}

function chainProfilesSelectNoRows(): ReturnType<typeof vi.fn> {
return chainProfilesSelect(null as unknown as { trial_uses_remaining: number | null }, { code: 'PGRST116', message: 'No rows found' });
}

function chainProfilesUpdate(updated: Array<{ id: string }> | null, error: { message?: string } | null = null): ReturnType<typeof vi.fn> {
const select = vi.fn().mockResolvedValue({ data: updated, error });
const eq = vi.fn().mockReturnValue({ select });
const update = vi.fn().mockReturnValue({ eq });
return { update, eq, select };

Check failure on line 54 in actions/trial.test.ts

View workflow job for this annotation

GitHub Actions / CI Checks / Code Quality

Object literal may only specify known properties, and 'update' does not exist in type 'Mock<Procedure | Constructable>'.

Check failure on line 54 in actions/trial.test.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Object literal may only specify known properties, and 'update' does not exist in type 'Mock<Procedure | Constructable>'.
}

beforeEach(() => {
vi.clearAllMocks();
});

describe('activateFreeTrial', () => {
it('should return error when user is not authenticated', async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });

const result = await activateFreeTrial();

expect(result.success).toBe(false);
expect(result.error).toBe('Unauthorized');
});

it('should return error when user already has active subscription', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
mockFrom
.mockReturnValueOnce(chainSubscriptions({ id: 'sub-1' }));

const result = await activateFreeTrial();

expect(result.success).toBe(false);
expect(result.error).toBe('You already have an active subscription.');
});

it('should return error when trial has already been activated', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
mockFrom
.mockReturnValueOnce(chainSubscriptions(null))
.mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: 3 }));

const result = await activateFreeTrial();

expect(result.success).toBe(false);
expect(result.error).toBe('Free trial has already been activated.');
});

it('should return error when profile update returns database error', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
mockFrom
.mockReturnValueOnce(chainSubscriptions(null))
.mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null }))
.mockReturnValueOnce(chainProfilesUpdate(null, { message: 'Permission denied' }));

const result = await activateFreeTrial();

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to activate trial');
});

it('should return error when update affects zero rows', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
mockFrom
.mockReturnValueOnce(chainSubscriptions(null))
.mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null }))
.mockReturnValueOnce(chainProfilesUpdate([]));

const result = await activateFreeTrial();

expect(result.success).toBe(false);
expect(result.error).toContain('Could not update your account');
expect(result.error).toContain('signing out and back in');
});

it('should activate trial and revalidate when profile exists and update succeeds', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
mockFrom
.mockReturnValueOnce(chainSubscriptions(null))
.mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null }))
.mockReturnValueOnce(chainProfilesUpdate([{ id: MOCK_USER.id }]));

const result = await activateFreeTrial();

expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
expect(mockFrom).toHaveBeenCalledWith('subscriptions');
expect(mockFrom).toHaveBeenCalledWith('profiles');
expect(mockRevalidatePath).toHaveBeenCalledWith('/app', 'layout');
});

it('should return error when profile fetch fails with non-PGRST116 error', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
mockFrom
.mockReturnValueOnce(chainSubscriptions(null))
.mockReturnValueOnce(
chainProfilesSelect(null as unknown as { trial_uses_remaining: number | null }, { code: 'PGRST500', message: 'Internal error' }),
);

const result = await activateFreeTrial();

expect(result.success).toBe(false);
expect(result.error).toBe('Could not load your profile. Please try again.');
});

it('should ensure profile exists when missing (PGRST116) then activate trial', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } });
const mockInsert = vi.fn().mockResolvedValue({ error: null });
mockServiceFrom.mockReturnValue({ insert: mockInsert });
mockFrom
.mockReturnValueOnce(chainSubscriptions(null))
.mockReturnValueOnce(chainProfilesSelectNoRows())
.mockReturnValueOnce(chainProfilesSelect({ trial_uses_remaining: null }))
.mockReturnValueOnce(chainProfilesUpdate([{ id: MOCK_USER.id }]));

const result = await activateFreeTrial();

expect(result.success).toBe(true);
expect(mockServiceFrom).toHaveBeenCalledWith('profiles');
expect(mockInsert).toHaveBeenCalledWith({
id: MOCK_USER.id,
full_name: 'Test User',
});
});
});
61 changes: 54 additions & 7 deletions actions/trial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
import { revalidatePath } from 'next/cache';

import { FREE_TRIAL_USES } from '@/lib/constants';
import { createServerClient } from '@/lib/supabase/server';
import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server';

const PGRST_NO_ROWS = 'PGRST116';

interface ITrialResult {
success: boolean;
error?: string;
}

function buildTrialErrorMessage(supabaseError: { message?: string; code?: string } | null): string {
const fallback = 'Failed to activate trial. Please try again.';
if (process.env.NODE_ENV !== 'development' || !supabaseError?.message) {
return fallback;
}
return `${fallback} (${supabaseError.message})`;
}

export async function activateFreeTrial(): Promise<ITrialResult> {
const supabase = await createServerClient();
const { data: { user } } = await supabase.auth.getUser();
Expand All @@ -30,25 +40,62 @@ export async function activateFreeTrial(): Promise<ITrialResult> {
return { success: false, error: 'You already have an active subscription.' };
}

// Check trial not already activated
const { data: profile } = await supabase
// Check trial not already activated; ensure profile exists if missing
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('trial_uses_remaining')
.eq('id', user.id)
.single();

if (profile?.trial_uses_remaining !== null) {
if (profileError?.code === PGRST_NO_ROWS) {
const service = createServiceRoleClient();
await service
.from('profiles')
.insert({
id: user.id,
full_name: (user.user_metadata?.full_name as string | undefined) ?? null,
});
// Retry profile fetch; if still missing or trial already set, handle below
const { data: retryProfile } = await supabase
.from('profiles')
.select('trial_uses_remaining')
.eq('id', user.id)
.single();
if (retryProfile !== null && retryProfile !== undefined && retryProfile.trial_uses_remaining !== null && retryProfile.trial_uses_remaining !== undefined) {
return { success: false, error: 'Free trial has already been activated.' };
}
} else if (profileError !== null && profileError !== undefined) {
console.error('[activateFreeTrial] profile fetch failed', {
code: profileError.code,
message: profileError.message,
});
return { success: false, error: 'Could not load your profile. Please try again.' };
} else if (profile !== null && profile !== undefined && profile.trial_uses_remaining !== null && profile.trial_uses_remaining !== undefined) {
return { success: false, error: 'Free trial has already been activated.' };
}

// Activate trial
const { error } = await supabase
const { data: updated, error } = await supabase
.from('profiles')
.update({ trial_uses_remaining: FREE_TRIAL_USES })
.eq('id', user.id);
.eq('id', user.id)
.select('id');

if (error) {
return { success: false, error: 'Failed to activate trial. Please try again.' };
console.error('[activateFreeTrial] profile update failed', {
code: error.code,
message: error.message,
details: error.details,
});
return { success: false, error: buildTrialErrorMessage(error) };
}

const noRowsUpdated = updated === null || (Array.isArray(updated) && updated.length === 0);
if (noRowsUpdated) {
return {
success: false,
error: 'Could not update your account. Try signing out and back in, or contact support.',
};
}

revalidatePath('/app', 'layout');
Expand Down
21 changes: 20 additions & 1 deletion app/(auth)/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,31 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
},
);

const { error } = await supabase.auth.exchangeCodeForSession(code);
const { data, error } = await supabase.auth.exchangeCodeForSession(code);

if (error) {
return NextResponse.redirect(new URL('/login?error=auth', baseUrl));
}

// After Twitter login, redirect to the platform connection flow if not yet connected.
// This ensures sign-in with Twitter also grants posting permissions (tweet.write).
if (data.user?.app_metadata?.provider === 'x' && next === '/app') {
const { data: existing } = await supabase
.from('platform_connections')
.select('id')
.eq('user_id', data.user.id)
.eq('platform', 'twitter')
.maybeSingle();

if (!existing) {
const response = NextResponse.redirect(new URL('/api/connect/twitter/authorize', baseUrl));
for (const { name, value, options } of pendingCookies) {
response.cookies.set(name, value, options);
}
return response;
}
}

const response = NextResponse.redirect(new URL(next, baseUrl));

for (const { name, value, options } of pendingCookies) {
Expand Down
14 changes: 10 additions & 4 deletions app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation';

import Composer from '@/components/composer/composer';
import { TRIAL_LIMITS } from '@/lib/constants';
import { mapMemoryItem, mapPlatformConnection, mapTwitterCommunity } from '@/lib/mappers';
import { createServerClient } from '@/lib/supabase/server';

Expand Down Expand Up @@ -88,17 +89,22 @@ export default async function ComposerPage(): Promise<React.ReactElement> {
// Platforms: system + user-owned (system platforms always available)
const platforms = activeItems.filter((i) => i.type === 'platform');

const hasSubscription = subscriptionResult.data !== null;
const maxVariations = subscriptionResult.data?.plans?.max_posts_per_generation ?? 1;
const creditBalance = (creditsResult.data?.balance as number | null) ?? 0;

const { data: profileData } = await supabase
.from('profiles')
.select('trial_uses_remaining')
.eq('id', user.id)
.single();
const trialUsesRemaining = (profileData?.trial_uses_remaining as number | null) ?? null;

const hasSubscription = subscriptionResult.data !== null;
const isTrial = !hasSubscription && trialUsesRemaining !== null && trialUsesRemaining > 0;
const maxVariations = hasSubscription
? (subscriptionResult.data?.plans?.max_posts_per_generation ?? 1)
: (isTrial ? TRIAL_LIMITS.MAX_VARIATIONS : 1);
const creditBalance = hasSubscription
? ((creditsResult.data?.balance as number | null) ?? 0)
: (trialUsesRemaining ?? 0);

return (
<Composer
spheres={spheres}
Expand Down
24 changes: 17 additions & 7 deletions app/app/plan/[planId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,27 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps):

if (!user) redirect('/login');

const { data: subscription } = await supabase
.from('subscriptions')
.select('*, plans(*)')
.eq('user_id', user.id)
.in('status', ['trialing', 'active'])
.single();
const [subscriptionResult, profileResult] = await Promise.all([
supabase
.from('subscriptions')
.select('*, plans(*)')
.eq('user_id', user.id)
.in('status', ['trialing', 'active'])
.single(),
supabase
.from('profiles')
.select('trial_uses_remaining')
.eq('id', user.id)
.single(),
]);

const subscription = subscriptionResult.data;
const planSlug = subscription?.plans?.slug as string | undefined;
const isBasicPlan = planSlug === PLAN_SLUGS.BASIC_MONTHLY || planSlug === PLAN_SLUGS.BASIC_YEARLY;
const trialUsesRemaining = (profileResult.data?.trial_uses_remaining as number | null) ?? null;
const hasTrialAccess = trialUsesRemaining !== null && trialUsesRemaining > 0;

if (isBasicPlan || !subscription) {
if (isBasicPlan || (!subscription && !hasTrialAccess)) {
redirect('/app/plan');
}

Expand Down
Loading
Loading