Skip to content
Open
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
123 changes: 123 additions & 0 deletions backend/convex/__tests__/supportReports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { convexTest } from 'convex-test';
import schema from '../schema';
import { api } from '../_generated/api';
import * as supportReportsModule from '../supportReports';
import * as apiModule from '../_generated/api';
import * as serverModule from '../_generated/server';
import { SUPPORT_REPORT_DESCRIPTION_MAX } from '@polybuys/shared';

const modules = {
'../supportReports.ts': () => Promise.resolve(supportReportsModule),
'../_generated/api.ts': () => Promise.resolve(apiModule),
'../_generated/server.ts': () => Promise.resolve(serverModule),
} as any;

function asReporter(t: any) {
return t.withIdentity({
name: 'Reporter',
subject: 'reporter-stable-id',
email: 'REPORTER@calpoly.edu',
});
}

describe('Support report mutations', () => {
it('submitSupportReport creates a durable support report with user and app context', async () => {
const t = convexTest(schema as any, modules);
const asUser = asReporter(t);

const result = await asUser.mutation(api.supportReports.submitSupportReport, {
category: 'bug',
description: 'The inbox spinner never goes away.',
context: {
platform: 'ios',
appVersion: '1.0.0',
osVersion: '17.6',
route: '/account-settings',
},
});

const report = await t.run(async (ctx: any) => {
return await ctx.db.get(result.supportReportId);
});

expect(report).toMatchObject({
reporterId: 'reporter-stable-id',
reporterEmail: 'reporter@calpoly.edu',
category: 'bug',
description: 'The inbox spinner never goes away.',
context: {
platform: 'ios',
appVersion: '1.0.0',
osVersion: '17.6',
route: '/account-settings',
},
});
expect(typeof report?.createdAt).toBe('number');
});

it('submitSupportReport requires authentication', async () => {
const t = convexTest(schema as any, modules);

await expect(async () => {
await t.mutation(api.supportReports.submitSupportReport, {
category: 'other',
description: 'I need help with my account.',
});
}).rejects.toThrow('You must be logged in to report a problem');
});

it('submitSupportReport rejects empty or too-long descriptions', async () => {
const t = convexTest(schema as any, modules);
const asUser = asReporter(t);

await expect(async () => {
await asUser.mutation(api.supportReports.submitSupportReport, {
category: 'other',
description: ' ',
});
}).rejects.toThrow('Description is required');

await expect(async () => {
await asUser.mutation(api.supportReports.submitSupportReport, {
category: 'bug',
description: 'a'.repeat(SUPPORT_REPORT_DESCRIPTION_MAX + 1),
});
}).rejects.toThrow(`Description must be ${SUPPORT_REPORT_DESCRIPTION_MAX} characters or less`);
});

it('submitSupportReport blocks duplicate rapid submissions', async () => {
const t = convexTest(schema as any, modules);
const asUser = asReporter(t);
const payload = {
category: 'messages' as const,
description: 'Messages are not sending.',
};

await asUser.mutation(api.supportReports.submitSupportReport, payload);

await expect(async () => {
await asUser.mutation(api.supportReports.submitSupportReport, payload);
}).rejects.toThrow('You already submitted this problem recently.');
});

it('submitSupportReport rate limits rapid support reports', async () => {
const t = convexTest(schema as any, modules);
const asUser = asReporter(t);

for (let i = 0; i < 3; i++) {
await asUser.mutation(api.supportReports.submitSupportReport, {
category: 'bug',
description: `Distinct bug report ${i}`,
});
}

await expect(async () => {
await asUser.mutation(api.supportReports.submitSupportReport, {
category: 'listing',
description: 'Another issue after several quick reports.',
});
}).rejects.toThrow('Support report limit reached. Please try again later.');
});
});
2 changes: 2 additions & 0 deletions backend/convex/__tests__/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as usersModule from '../users';
import * as messagesModule from '../messages';
import * as blocksModule from '../blocks';
import * as reportsModule from '../reports';
import * as supportReportsModule from '../supportReports';
import * as moderationModule from '../moderation';
import * as pushNotificationsModule from '../pushNotifications';
import * as apiModule from '../_generated/api';
Expand All @@ -26,6 +27,7 @@ export const modules = {
'../messages.ts': () => Promise.resolve(messagesModule),
'../blocks.ts': () => Promise.resolve(blocksModule),
'../reports.ts': () => Promise.resolve(reportsModule),
'../supportReports.ts': () => Promise.resolve(supportReportsModule),
'../moderation.ts': () => Promise.resolve(moderationModule),
'../pushNotifications.ts': () => Promise.resolve(pushNotificationsModule),
'../_generated/api.ts': () => Promise.resolve(apiModule),
Expand Down
2 changes: 2 additions & 0 deletions backend/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type * as profiles from "../profiles.js";
import type * as pushNotifications from "../pushNotifications.js";
import type * as reports from "../reports.js";
import type * as savedListings from "../savedListings.js";
import type * as supportReports from "../supportReports.js";
import type * as users from "../users.js";

import type {
Expand Down Expand Up @@ -53,6 +54,7 @@ declare const fullApi: ApiFromModules<{
pushNotifications: typeof pushNotifications;
reports: typeof reports;
savedListings: typeof savedListings;
supportReports: typeof supportReports;
users: typeof users;
}>;

Expand Down
28 changes: 28 additions & 0 deletions backend/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,34 @@ export default defineSchema({
.index('by_target', ['targetId', 'targetType'])
.index('by_reporter', ['reporterId']),

supportReports: defineTable({
reporterId: v.string(),
reporterEmail: v.optional(v.string()),
category: v.union(
v.literal('bug'),
v.literal('account_login'),
v.literal('listing'),
v.literal('messages'),
v.literal('payments_offers'),
v.literal('safety'),
v.literal('other')
),
description: v.string(),
context: v.optional(
v.object({
platform: v.optional(v.string()),
appVersion: v.optional(v.string()),
osVersion: v.optional(v.string()),
route: v.optional(v.string()),
listingId: v.optional(v.string()),
conversationId: v.optional(v.string()),
})
),
createdAt: v.number(),
})
.index('by_reporter_createdAt', ['reporterId', 'createdAt'])
.index('by_createdAt', ['createdAt']),

conversations: defineTable({
listingId: v.id('listings'),
buyerId: v.string(), // Auth identity subject
Expand Down
138 changes: 138 additions & 0 deletions backend/convex/supportReports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ConvexError, v } from 'convex/values';
import {
SUPPORT_REPORT_CONTEXT_VALUE_MAX,
SUPPORT_REPORT_DESCRIPTION_MAX,
SUPPORT_REPORTS_PER_DAY,
SUPPORT_REPORTS_PER_TEN_MINUTES,
} from '@polybuys/shared';
import { mutation } from './_generated/server';
import { requireAuthUserId } from './lib/authIdentity';

const TEN_MINUTES_MS = 10 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;

type SupportReportContext = {
platform?: string;
appVersion?: string;
osVersion?: string;
route?: string;
listingId?: string;
conversationId?: string;
};

function cleanOptionalText(value: string | undefined, maxLength: number) {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.slice(0, maxLength);
}

function cleanContext(context: SupportReportContext | undefined) {
if (!context) {
return undefined;
}

const cleaned: SupportReportContext = {};
const platform = cleanOptionalText(context.platform, SUPPORT_REPORT_CONTEXT_VALUE_MAX);
const appVersion = cleanOptionalText(context.appVersion, SUPPORT_REPORT_CONTEXT_VALUE_MAX);
const osVersion = cleanOptionalText(context.osVersion, SUPPORT_REPORT_CONTEXT_VALUE_MAX);
const route = cleanOptionalText(context.route, SUPPORT_REPORT_CONTEXT_VALUE_MAX);
const listingId = cleanOptionalText(context.listingId, SUPPORT_REPORT_CONTEXT_VALUE_MAX);
const conversationId = cleanOptionalText(
context.conversationId,
SUPPORT_REPORT_CONTEXT_VALUE_MAX
);

if (platform) cleaned.platform = platform;
if (appVersion) cleaned.appVersion = appVersion;
if (osVersion) cleaned.osVersion = osVersion;
if (route) cleaned.route = route;
if (listingId) cleaned.listingId = listingId;
if (conversationId) cleaned.conversationId = conversationId;

return Object.keys(cleaned).length > 0 ? cleaned : undefined;
}

export const submitSupportReport = mutation({
args: {
category: v.union(
v.literal('bug'),
v.literal('account_login'),
v.literal('listing'),
v.literal('messages'),
v.literal('payments_offers'),
v.literal('safety'),
v.literal('other')
),
description: v.string(),
context: v.optional(
v.object({
platform: v.optional(v.string()),
appVersion: v.optional(v.string()),
osVersion: v.optional(v.string()),
route: v.optional(v.string()),
listingId: v.optional(v.string()),
conversationId: v.optional(v.string()),
})
),
},
handler: async (ctx, args) => {
const reporterId = await requireAuthUserId(ctx, 'You must be logged in to report a problem');
const description = args.description.trim();

if (!description) {
throw new ConvexError('Description is required');
}

if (description.length > SUPPORT_REPORT_DESCRIPTION_MAX) {
throw new ConvexError(
`Description must be ${SUPPORT_REPORT_DESCRIPTION_MAX} characters or less`
);
}

const now = Date.now();
const recentCutoff = now - TEN_MINUTES_MS;
const recentReports = await ctx.db
.query('supportReports')
.withIndex('by_reporter_createdAt', (q) =>
q.eq('reporterId', reporterId).gt('createdAt', recentCutoff)
)
.collect();

const matchingRecentReport = recentReports.find(
(report) => report.category === args.category && report.description === description
);
if (matchingRecentReport) {
throw new ConvexError('You already submitted this problem recently.');
}

if (recentReports.length >= SUPPORT_REPORTS_PER_TEN_MINUTES) {
throw new ConvexError('Support report limit reached. Please try again later.');
}

const oneDayAgo = now - ONE_DAY_MS;
const dailyReports = await ctx.db
.query('supportReports')
.withIndex('by_reporter_createdAt', (q) =>
q.eq('reporterId', reporterId).gt('createdAt', oneDayAgo)
)
.collect();
if (dailyReports.length >= SUPPORT_REPORTS_PER_DAY) {
throw new ConvexError('Support report limit reached. Please try again later.');
}

const identity = await ctx.auth.getUserIdentity();
const reporterEmail = cleanOptionalText(identity?.email?.toLowerCase(), 320);
const supportReportId = await ctx.db.insert('supportReports', {
reporterId,
reporterEmail,
category: args.category,
description,
context: cleanContext(args.context),
createdAt: now,
});

return { supportReportId };
},
});
10 changes: 10 additions & 0 deletions frontend/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ function RootLayout() {
name="account-settings"
options={{ title: 'Settings', headerBackTitle: 'Profile' }}
/>
<Stack.Screen
name="report-problem"
options={{
title: 'Report a Problem',
headerBackTitle: 'Settings',
presentation: Platform.OS === 'ios' ? 'formSheet' : 'modal',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.75, 1.0],
}}
/>
<Stack.Screen
name="profile/edit"
options={{ title: 'Edit Profile', headerBackTitle: 'Profile' }}
Expand Down
Loading
Loading