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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ jobs:
- name: Test ws-server
run: pnpm --filter ws-server run test

# The auth middleware tests sign real tokens and need no services.
- name: Test http-server
run: pnpm --filter http-server run test

- name: Test frontend
run: pnpm --filter frontend run test
# Who may join a video room, speak or moderate. Postgres and the
Expand Down
52 changes: 52 additions & 0 deletions apps/frontend/src/screens/QuizBuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,55 @@ describe('QuizBuilder error states', () => {
expect(api.post).toHaveBeenCalledWith('/quizzes/quiz-1/generate-access-code');
});
});


describe('QuizBuilder co-organizers', () => {
it('lists the owner, accepted and pending organizers from quizOrganizers', async () => {
vi.mocked(api.get).mockResolvedValue({
data: {
...quiz,
quizOrganizers: [
{ id: 'o1', role: 'OWNER', inviteStatus: 'ACCEPTED', inviteEmail: null, user: { name: 'Olivia Owner' } },
{ id: 'o2', role: 'CO_ORGANIZER', inviteStatus: 'ACCEPTED', inviteEmail: 'sam@example.com', user: { name: 'Sam' } },
{ id: 'o3', role: 'CO_ORGANIZER', inviteStatus: 'PENDING', inviteEmail: 'new@example.com', user: null },
],
},
});

renderBuilder();

expect(await screen.findByText('Olivia Owner')).toBeInTheDocument();
expect(screen.getByText('OWNER')).toBeInTheDocument();
expect(screen.getByText('Sam')).toBeInTheDocument();
expect(screen.getByText('ACCEPTED')).toBeInTheDocument();
expect(screen.getByText('new@example.com')).toBeInTheDocument();
expect(screen.getByText('PENDING')).toBeInTheDocument();
});

it('shows the reason the server gave when an invite is refused', async () => {
vi.mocked(api.post).mockRejectedValueOnce({
response: { data: { success: false, error: 'Already invited' } },
});

const user = renderBuilder();
await screen.findByText('What is ATP?');

await user.type(screen.getByPlaceholderText('Email'), 'sam@example.com');
await user.click(screen.getByRole('button', { name: /send/i }));

expect(await screen.findByText('Already invited')).toBeInTheDocument();
expect(api.post).toHaveBeenCalledWith('/quizzes/quiz-1/invite', { email: 'sam@example.com' });
});

it('falls back to a generic message when the server gives no reason', async () => {
vi.mocked(api.post).mockRejectedValueOnce(new Error('network down'));

const user = renderBuilder();
await screen.findByText('What is ATP?');

await user.type(screen.getByPlaceholderText('Email'), 'sam@example.com');
await user.click(screen.getByRole('button', { name: /send/i }));

expect(await screen.findByText('Failed to invite.')).toBeInTheDocument();
});
});
10 changes: 7 additions & 3 deletions apps/frontend/src/screens/QuizBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
if (id) {
fetchQuiz();
}
}, [id]);

Check warning on line 61 in apps/frontend/src/screens/QuizBuilder.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

React Hook useEffect has a missing dependency: 'fetchQuiz'. Either include it or remove the dependency array


const fetchQuiz = async () => {
Expand Down Expand Up @@ -128,9 +128,13 @@

setInviteMsg('Invite sent successfully!');
setInviteEmail('');
// pull the new pending invite into the sidebar
fetchQuiz();
setTimeout(() => setInviteMsg(''), 3000);
} catch (err) {
setInviteMsg('Failed to invite.');
// the server says why it refused (already invited, bad email...)
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setInviteMsg(message || 'Failed to invite.');
setInviteEmail('');
setTimeout(() => setInviteMsg(''), 3000);
}
Expand Down Expand Up @@ -447,10 +451,10 @@
<LuUserSearch className="w-5 h-5" /> Co-Organizers
</h3>
<div className="space-y-3 mb-6">
{quiz?.organizers?.map((org: any) => (
{quiz?.quizOrganizers?.map((org: any) => (
<div key={org.id} className="flex justify-between items-center text-sm">
<span className="truncate pr-2 text-gray-50">{org.user?.name || org.inviteEmail}</span>
<span className="text-xs bg-white/10 px-2 py-0.5 rounded">{org.inviteStatus}</span>
<span className="text-xs bg-white/10 px-2 py-0.5 rounded">{org.role === 'OWNER' ? 'OWNER' : org.inviteStatus}</span>
</div>
))}
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/http-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "vitest run",
"build": "tsc -b",
"dev": "tsc -b && node dist/index.js",
"start": "node dist/index.js"
Expand Down Expand Up @@ -38,6 +39,7 @@
"ts-node-dev": "^2.0.0",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"vitest": "^5.0.2",
"wait-on": "^9.0.4"
}
}
48 changes: 44 additions & 4 deletions apps/http-server/src/controllers/inviteController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import { Response, Request } from "express"
import { errorResponse } from "../utils/error";
import { v4 as uuidv4 } from "uuid";
import { sendInviteEmail } from "../utils/email";
import { parseInviteEmail, sameEmail } from "../utils/inviteEmail";

// Prisma's unique constraint error, e.g. two requests racing past the checks below
const isUniqueViolation = (error: unknown) => (error as { code?: string })?.code === 'P2002';

export const inviteCoOrganizerController = async (req: Request, res: Response) => {
try {

console.log("API hit ho rhi hai bhai....")

const quizId = req.params.quizId;
const { email } = req.body;
const parsed = parseInviteEmail(req.body?.email);
if ('error' in parsed) return errorResponse(res, 400, parsed.error);
const { email } = parsed;

const quiz = await prisma.quiz.findUnique({
where: {
Expand All @@ -23,12 +29,29 @@ export const inviteCoOrganizerController = async (req: Request, res: Response) =

if (quiz.creatorId !== req.userId) return errorResponse(res, 403, 'Only Owner can invite...');

const targetUser = await prisma.user.findUnique({
// emails are matched case-insensitively, older rows may not be lower case
const targetUser = await prisma.user.findFirst({
where: {
email
email: { equals: email, mode: 'insensitive' }
}
});

if (targetUser && targetUser.id === quiz.creatorId) return errorResponse(res, 409, 'You cannot invite yourself');

const existing = await prisma.quizOrganizer.findFirst({
where: {
quizId: quizId as string,
OR: [
...(targetUser ? [{ userId: targetUser.id }] : []),
{ inviteEmail: { equals: email, mode: 'insensitive' as const } }
]
}
});

if (existing) {
return errorResponse(res, 409, existing.inviteStatus === 'ACCEPTED' ? 'Already an organizer' : 'Already invited');
}

const inviteToken = uuidv4();

// if the user exists already then simply it added to that user as co-organizer otherwise create a new user...
Expand Down Expand Up @@ -59,6 +82,7 @@ export const inviteCoOrganizerController = async (req: Request, res: Response) =
})

} catch (error) {
if (isUniqueViolation(error)) return errorResponse(res, 409, 'Already invited');
console.log('Error while sending mail...', error);
return errorResponse(res, 500, 'Internal Server Error');
}
Expand All @@ -84,7 +108,22 @@ export const acceptInvitationController = async (req: Request, res: Response) =>
},
});

if (invite.inviteEmail && invite.inviteEmail !== user?.email) return errorResponse(res, 400, 'Email is not matching. Try again later');
if (invite.inviteEmail && !sameEmail(invite.inviteEmail, user?.email)) return errorResponse(res, 400, 'Email is not matching. Try again later');

// already an organizer of this quiz through another row (e.g. an older duplicate invite),
// so linking this one would break the unique (userId, quizId) constraint
const alreadyOrganizer = await prisma.quizOrganizer.findFirst({
where: {
quizId: invite.quizId,
userId: req.userId,
id: { not: invite.id }
}
});

if (alreadyOrganizer) {
await prisma.quizOrganizer.delete({ where: { id: invite.id } });
return errorResponse(res, 409, 'You are already an organizer of this quiz');
}

await prisma.quizOrganizer.update({
where: { id: invite.id },
Expand All @@ -100,6 +139,7 @@ export const acceptInvitationController = async (req: Request, res: Response) =>
})

} catch (error) {
if (isUniqueViolation(error)) return errorResponse(res, 409, 'You are already an organizer of this quiz');
console.log("eror while accepting the invitation", error);
return errorResponse(res, 500, 'Internal Server Error...')
}
Expand Down
37 changes: 37 additions & 0 deletions apps/http-server/src/utils/inviteEmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { normalizeEmail, parseInviteEmail, sameEmail } from './inviteEmail';

describe('parseInviteEmail', () => {
it('trims and lower cases a valid email', () => {
expect(parseInviteEmail(' Friend@Example.COM ')).toEqual({ email: 'friend@example.com' });
});

it.each([undefined, null, '', ' ', 42, {}])('asks for an email when given %j', (value) => {
expect(parseInviteEmail(value)).toEqual({ error: 'Email is required' });
});

it.each(['not-an-email', 'a@b', '@example.com', 'two words@example.com'])(
'rejects %s as invalid',
(value) => {
expect(parseInviteEmail(value)).toEqual({ error: 'Enter a valid email address' });
}
);
});

describe('sameEmail', () => {
it('ignores case and surrounding spaces on both sides', () => {
expect(sameEmail('Friend@Example.com', ' friend@example.COM')).toBe(true);
});

it('is false for different or missing emails', () => {
expect(sameEmail('a@example.com', 'b@example.com')).toBe(false);
expect(sameEmail(null, 'b@example.com')).toBe(false);
expect(sameEmail('a@example.com', undefined)).toBe(false);
});
});

describe('normalizeEmail', () => {
it('lower cases and trims', () => {
expect(normalizeEmail(' A@B.com ')).toBe('a@b.com');
});
});
19 changes: 19 additions & 0 deletions apps/http-server/src/utils/inviteEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { z } from "zod";

const emailSchema = z.email();

// Trim + lower case so the same address is always stored (and compared) one way.
export const normalizeEmail = (value: string) => value.trim().toLowerCase();

export const sameEmail = (a?: string | null, b?: string | null) =>
!!a && !!b && normalizeEmail(a) === normalizeEmail(b);

// Checks the `email` field of an invite request body.
export const parseInviteEmail = (value: unknown): { email: string } | { error: string } => {
if (typeof value !== 'string' || !value.trim()) return { error: 'Email is required' };

const email = normalizeEmail(value);
if (!emailSchema.safeParse(email).success) return { error: 'Enter a valid email address' };

return { email };
};
3 changes: 2 additions & 1 deletion apps/http-server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
// Tests run through vitest and are not part of the shipped build.
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading