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
54 changes: 54 additions & 0 deletions backend/convex/__tests__/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,58 @@ describe('Profiles mutations', () => {
});
}).rejects.toThrowError('Email is required to create a profile');
});

it('updateProfile ignores attempts to change the stored email', async () => {
const t = convexTest(schema as any, modules);
const asUser = t.withIdentity({
name: 'Alice',
subject: 'alice-stable-id',
email: 'alice@calpoly.edu',
});

const profileId = await asUser.mutation(api.profiles.createProfile, {
name: 'Alice',
major: 'Computer Science',
year: 2026,
});

await asUser.mutation(api.profiles.updateProfile, {
name: 'Alice Updated',
email: 'updated@calpoly.edu',
});

const profile = await t.run(async (ctx) => {
return await ctx.db.get(profileId);
});

expect(profile?.name).toBe('Alice Updated');
expect(profile?.email).toBe('alice@calpoly.edu');
});

it('updateProfile rejects email-only updates and keeps the stored email unchanged', async () => {
const t = convexTest(schema as any, modules);
const asUser = t.withIdentity({
name: 'Alice',
subject: 'alice-stable-id',
email: 'alice@calpoly.edu',
});

const profileId = await asUser.mutation(api.profiles.createProfile, {
name: 'Alice',
major: 'Computer Science',
year: 2026,
});

await expect(
asUser.mutation(api.profiles.updateProfile, {
email: 'updated@calpoly.edu',
})
).rejects.toThrowError('No valid fields to update');

const profile = await t.run(async (ctx) => {
return await ctx.db.get(profileId);
});

expect(profile?.email).toBe('alice@calpoly.edu');
});
});
5 changes: 2 additions & 3 deletions backend/convex/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,8 @@ export const updateProfile = mutation({
}
update.name = args.name;
}
if (args.email !== undefined) {
update.email = normalizeEmailInput(args.email);
}
// Backwards compatibility: released clients still send `email` on profile updates.
// Ignore it so the authenticated Cal Poly email remains immutable after onboarding.
if (args.bio !== undefined) {
if (args.bio.length > PAYLOAD_BOUNDS.BIO_MAX) {
throw new ConvexError(`Bio must be ${PAYLOAD_BOUNDS.BIO_MAX} characters or less`);
Expand Down
2 changes: 2 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ EXPO_PUBLIC_APP_ORIGIN=https://polybuys.com
EXPO_PUBLIC_SUPPORT_EMAIL=support@polybuys.com
EXPO_PUBLIC_ENABLE_SENTRY_PII=false
EXPO_PUBLIC_APP_REVIEW_EMAIL=
# Optional fallback if the EAS project ID is not embedded in app config.
EXPO_PUBLIC_EAS_PROJECT_ID=
48 changes: 43 additions & 5 deletions frontend/app/(tabs)/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FilterBar } from '../../components/FilterBar';
import { CategoryPicker } from '../../components/CategoryPicker';
import OpenInAppPrompt from '../../components/OpenInAppPrompt';
import { PriceRangePicker } from '../../components/PriceRangePicker';
import { SortPicker } from '../../components/SortPicker';
import ListingCard from '../../components/ListingCard';
Expand All @@ -28,11 +29,20 @@ import type { Filters, Category, ListingSortBy } from '../../types/filters';
import { useAuth } from '../../hooks/useAuth';
import type { Doc, Id } from 'convex/_generated/dataModel';
import { useEntranceAnimation } from '../../hooks/useEntranceAnimation';
import { getUserFlowErrorMessage } from '../../lib/user-flow-errors';
import { borderRadius, colors, spacing, typography } from '../../theme/tokens';

const PAGE_SIZE = 20;
const FOCUS_REFETCH_STALE_MS = 45_000;

type WebHandoffPrompt = {
key: 'create-listing' | 'save-listing';
title: string;
body: string;
path: string;
buttonLabel: string;
};

function buildWebRows(items: Doc<'listings'>[], size: number) {
const rows: Array<{
key: string;
Expand Down Expand Up @@ -72,6 +82,7 @@ export default function HomeScreen() {
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [showPricePicker, setShowPricePicker] = useState(false);
const [showSortPicker, setShowSortPicker] = useState(false);
const [webHandoffPrompt, setWebHandoffPrompt] = useState<WebHandoffPrompt | null>(null);

const [allListings, setAllListings] = useState<Doc<'listings'>[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
Expand Down Expand Up @@ -203,7 +214,13 @@ export default function HomeScreen() {
const handleToggleSave = useCallback(
async (listingId: Id<'listings'>) => {
if (isWeb) {
Alert.alert('Open in the PolyBuys app', 'Saving listings is available in the mobile app.');
setWebHandoffPrompt({
key: 'save-listing',
title: 'Save listings in the mobile app',
body: 'Bookmarks and saved listings are available in the PolyBuys mobile app.',
path: `/listings/${listingId}`,
buttonLabel: 'Open Listing in App',
});
return;
}

Expand All @@ -215,9 +232,7 @@ export default function HomeScreen() {
try {
await toggleSavedListing({ listingId });
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unable to save listing right now.';
Alert.alert('Save failed', message);
Alert.alert('Save Failed', getUserFlowErrorMessage(error, 'save-listing'));
}
},
[isAuthenticated, isWeb, router, toggleSavedListing]
Expand Down Expand Up @@ -262,7 +277,13 @@ export default function HomeScreen() {

const handleCreateListing = () => {
if (isWeb) {
Alert.alert('Open in the PolyBuys app', 'Creating listings is available in the mobile app.');
setWebHandoffPrompt({
key: 'create-listing',
title: 'Create listings in the mobile app',
body: 'Posting items is available in the PolyBuys mobile app.',
path: '/listings/new',
buttonLabel: 'Open Create Listing in App',
});
return;
}

Expand Down Expand Up @@ -424,6 +445,20 @@ export default function HomeScreen() {
onClearAll={handleClearAll}
/>

{webHandoffPrompt ? (
<OpenInAppPrompt
key={webHandoffPrompt.key}
variant="card"
title={webHandoffPrompt.title}
body={webHandoffPrompt.body}
path={webHandoffPrompt.path}
buttonLabel={webHandoffPrompt.buttonLabel}
secondaryActionLabel="Keep browsing"
onSecondaryAction={() => setWebHandoffPrompt(null)}
cardStyle={styles.webHandoffCard}
/>
) : null}

{!hasLoadedOnceRef.current && listingsResult === undefined && cursor === null ? (
<View style={styles.centerContainer}>
<View style={styles.stateCard}>
Expand Down Expand Up @@ -651,6 +686,9 @@ const styles = StyleSheet.create({
webGrid: {
gap: spacing.lg,
},
webHandoffCard: {
maxWidth: '100%',
},
webGridItem: {
flex: 1,
minWidth: 0,
Expand Down
30 changes: 9 additions & 21 deletions frontend/app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import {
Animated,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
import { useRouter } from 'expo-router';
import { api } from 'convex/_generated/api';
Expand All @@ -20,7 +18,8 @@ import ListingCard from '../../components/ListingCard';
import OpenInAppPrompt from '../../components/OpenInAppPrompt';
import ProfileAvatar from '../../components/ProfileAvatar';
import { ScreenState } from '../../components/ScreenState';
import { FilterChips, type FilterChipOption } from '../../components/ui';
import { formatMajorLabel } from '../../constants/calPolyMajors';
import { FilterChips, ScreenScrollView, type FilterChipOption } from '../../components/ui';
import { colors, typography, spacing, borderRadius } from '../../theme/tokens';
import type { Doc } from 'convex/_generated/dataModel';

Expand All @@ -45,11 +44,9 @@ const ACCOUNT_SETTINGS = '/account-settings';
export default function SettingsScreen() {
const router = useRouter();
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const isWeb = Platform.OS === 'web';
const { isAuthenticated, isSessionLoading } = useAuth();
const entranceStyle = useEntranceAnimation();
const topSafeSpace = Platform.OS === 'ios' ? Math.max(insets.top - 6, 10) : 0;

const [activeTab, setActiveTab] = useState<TabId>('listings');
const profile = useQuery(api.profiles.getCurrentProfile, isAuthenticated && !isWeb ? {} : 'skip');
Expand Down Expand Up @@ -109,11 +106,7 @@ export default function SettingsScreen() {

if (!profile) {
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.centeredState}
contentInsetAdjustmentBehavior="never"
>
<ScreenScrollView style={styles.container} contentContainerStyle={styles.centeredState}>
<Animated.View style={[styles.signInCard, entranceStyle]}>
<Text style={styles.signInTitle}>Complete your profile</Text>
<Text style={styles.signInBody}>
Expand All @@ -135,21 +128,16 @@ export default function SettingsScreen() {
<Text style={styles.settingsRowChevron}>›</Text>
</Pressable>
</Animated.View>
</ScrollView>
</ScreenScrollView>
);
}

const yearLabel = yearToOrdinal(profile.year);

const profileSubtitle = `${profile.major} • ${yearLabel}`;
const profileSubtitle = `${formatMajorLabel(profile.major)} • ${yearLabel}`;

return (
<ScrollView
style={styles.container}
contentInsetAdjustmentBehavior="never"
contentContainerStyle={styles.content}
>
{topSafeSpace > 0 && <View style={{ height: topSafeSpace }} />}
<ScreenScrollView style={styles.container} contentContainerStyle={styles.content}>
<Animated.View style={[styles.profileBlock, entranceStyle]}>
<View style={styles.profileHeader}>
<ProfileAvatar uri={avatarUrl} name={profile.name} size={72} style={styles.avatar} />
Expand Down Expand Up @@ -272,7 +260,7 @@ export default function SettingsScreen() {
)}
</View>
)}
</ScrollView>
</ScreenScrollView>
);
}

Expand Down Expand Up @@ -440,7 +428,7 @@ const styles = StyleSheet.create({
},
primaryPill: {
flex: 1,
minHeight: 48,
minHeight: 44,
borderRadius: borderRadius.full,
borderWidth: 1,
borderColor: colors.primary,
Expand All @@ -456,7 +444,7 @@ const styles = StyleSheet.create({
},
secondaryPill: {
flex: 1,
minHeight: 48,
minHeight: 44,
borderRadius: borderRadius.full,
borderWidth: 1,
borderColor: colors.border,
Expand Down
6 changes: 4 additions & 2 deletions frontend/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Platform, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { FlashProvider } from '../contexts/FlashContext';
import { usePushNotifications } from '../hooks/usePushNotifications';
import { getRequiredExpoPublicEnv } from '../lib/env';
import { colors, typography } from '../theme/tokens';

const navigationTheme = {
Expand All @@ -27,18 +28,19 @@ const navigationTheme = {
};

const allowSentryPii = process.env.EXPO_PUBLIC_ENABLE_SENTRY_PII === 'true';
const convexUrl = getRequiredExpoPublicEnv('EXPO_PUBLIC_CONVEX_URL');
export const unstable_settings = {
initialRouteName: '(tabs)',
};

Sentry.init({
dsn: 'https://ed516d30275214d7429df46a33c04764@o4510288242933760.ingest.us.sentry.io/4511024032382976',
sendDefaultPii: allowSentryPii,
enableLogs: true,
enableLogs: __DEV__,
// spotlight: __DEV__,
});

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
const convex = new ConvexReactClient(convexUrl, {
unsavedChangesWarning: false,
});

Expand Down
Loading
Loading