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
112 changes: 110 additions & 2 deletions imports/features/notifications/NotificationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import { faCheckDouble, faCircleInfo, faTrash, faXmark } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Text } from '@mieweb/ui';
import { Button, Modal, ModalBody, ModalClose, ModalFooter, ModalHeader, ModalTitle, Text } from '@mieweb/ui';
import { useFind, useSubscribe } from 'meteor/react-meteor-data';
import React, { useCallback, useEffect, useMemo, useState } from 'react';

Expand All @@ -16,6 +16,23 @@ import { useRouter } from '../../ui/router';
import { Notifications } from '../teams/api';
import type { NotificationDoc } from '../teams/schema';

type UserSummary = {
id: string;
name: string;
email: string;
};

type TeamInvitePreview = {
notificationId: string;
teamId: string;
teamName: string;
teamDescription: string;
inviter: UserSummary | null;
members: UserSummary[];
admins: UserSummary[];
alreadyMember: boolean;
};

function idStr(id: unknown): string {
if (id == null) return '';
if (typeof id === 'string') return id;
Expand Down Expand Up @@ -116,6 +133,14 @@ export const NotificationsPage: React.FC = () => {
const markAsRead = useMethod<[unknown], void>('notifications.markAsRead');
const markAllAsRead = useMethod<[], void>('notifications.markAllAsRead');
const deleteNotifications = useMethod<[string[]], { deletedCount: number }>('notifications.delete');
const getInvitePreview = useMethod<[unknown], TeamInvitePreview>('notifications.getTeamInvitePreview');
const respondToInvite = useMethod<
[{ notificationId: unknown; action: 'join' | 'ignore' }],
{ success: true }
>('notifications.respondToTeamInvite');

const [invitePreview, setInvitePreview] = useState<TeamInvitePreview | null>(null);
const [inviteError, setInviteError] = useState<string | null>(null);

const hasUnread = useMemo(() => notifications.some((n) => !n.read), [notifications]);
const allIds = useMemo(() => notifications.map((n) => idStr(n._id)), [notifications]);
Expand All @@ -138,13 +163,21 @@ export const NotificationsPage: React.FC = () => {
return;
}
try {
const data = (doc.data ?? {}) as Record<string, unknown>;
if (data.type === 'team-invite') {
await markAsRead.call(doc._id ?? nid);
const preview = await getInvitePreview.call(doc._id ?? nid);
setInvitePreview(preview);
setInviteError(null);
return;
}
await markAsRead.call(doc._id ?? nid);
resolveNotificationTarget(doc, navigate);
} catch {
/* useMethod surfaces error */
}
},
[selectMode, toggleSelect, markAsRead, navigate],
[selectMode, toggleSelect, markAsRead, getInvitePreview, navigate],
);

const handleMarkAllRead = useCallback(() => {
Expand All @@ -165,6 +198,22 @@ export const NotificationsPage: React.FC = () => {
if (selectMode && notifications.length === 0) exitSelectMode();
}, [selectMode, notifications.length, exitSelectMode]);

const closeInviteModal = useCallback(() => {
setInvitePreview(null);
setInviteError(null);
}, []);

const handleInviteAction = useCallback(async (action: 'join' | 'ignore') => {
if (!invitePreview) return;
try {
await respondToInvite.call({ notificationId: invitePreview.notificationId, action });
closeInviteModal();
if (action === 'join') navigate('/app/teams');
} catch (e: any) {
setInviteError(e?.reason || 'Failed to process invite');
}
}, [invitePreview, respondToInvite, closeInviteModal, navigate]);

if (loading()) {
return (
<div className="flex items-center justify-center p-16">
Expand Down Expand Up @@ -305,6 +354,65 @@ export const NotificationsPage: React.FC = () => {
</Text>
</div>
)}

<Modal open={!!invitePreview} onOpenChange={(open) => !open && closeInviteModal()} size="lg">
<ModalHeader>
<ModalTitle>Team Invite</ModalTitle>
<ModalClose />
</ModalHeader>
<ModalBody>
{invitePreview && (
<div className="space-y-4">
<div>
<Text size="sm" variant="muted">Team name</Text>
<Text size="lg" weight="semibold">{invitePreview.teamName}</Text>
</div>
<div>
<Text size="sm" variant="muted">Team description</Text>
<Text size="sm">
{invitePreview.teamDescription || 'No team description provided.'}
</Text>
</div>
<div>
<Text size="sm" variant="muted">Invited by</Text>
<Text size="sm">
{invitePreview.inviter
? `${invitePreview.inviter.name}${invitePreview.inviter.email ? ` (${invitePreview.inviter.email})` : ''}`
: 'Unknown'}
</Text>
</div>
<div>
<Text size="sm" variant="muted">Admins ({invitePreview.admins.length})</Text>
<Text size="sm">
{invitePreview.admins.map((a) => a.name).join(', ') || 'None'}
</Text>
</div>
<div>
<Text size="sm" variant="muted">Team members ({invitePreview.members.length})</Text>
<Text size="sm">
{invitePreview.members.map((m) => m.name).join(', ') || 'None'}
</Text>
</div>
{inviteError && (
<Text size="sm" variant="destructive">{inviteError}</Text>
)}
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="outline" onClick={() => handleInviteAction('ignore')} isLoading={respondToInvite.loading}>
Ignore
</Button>
<Button
variant="primary"
onClick={() => handleInviteAction('join')}
isLoading={respondToInvite.loading}
disabled={invitePreview?.alreadyMember}
>
{invitePreview?.alreadyMember ? 'Already in team' : 'Join'}
</Button>
</ModalFooter>
</Modal>
</div>
);
};
95 changes: 89 additions & 6 deletions imports/features/teams/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ function getUserDisplayName(user: Meteor.User, fallback = 'Unknown'): string {
return user.emails?.[0]?.address?.split('@')[0] ?? fallback;
}

type BasicUserInfo = { id: string; name: string; email: string };

async function getBasicUsers(userIds: string[]): Promise<BasicUserInfo[]> {
if (!userIds.length) return [];
const users = await Meteor.users.find({ _id: { $in: userIds } }).fetchAsync();
return users.map((u) => ({
id: u._id,
name: getUserDisplayName(u),
email: u.emails?.[0]?.address ?? '',
}));
}

async function requireTeamAdmin(teamId: string, userId: string): Promise<TeamDoc> {
const team = await Teams.findOneAsync(teamId);
if (!team) throw new Meteor.Error('not-found', 'Team not found');
Expand Down Expand Up @@ -82,6 +94,8 @@ if (Meteor.isServer) {
'notifications.markAsRead',
'notifications.markAllAsRead',
'notifications.delete',
'notifications.getTeamInvitePreview',
'notifications.respondToTeamInvite',
];
DDPRateLimiter.addRule({ name: (n) => methodNames.includes(n), userId: () => true }, 20, 60_000);
});
Expand Down Expand Up @@ -238,12 +252,7 @@ if (Meteor.isServer) {
if (!this.userId) throw new Meteor.Error('not-authorized');
if (!Array.isArray(userIds) || userIds.length === 0) return [];
const safeIds = userIds.slice(0, 200);
const users = await Meteor.users.find({ _id: { $in: safeIds } }).fetchAsync();
return users.map((u) => ({
id: u._id,
name: getUserDisplayName(u),
email: u.emails?.[0]?.address ?? '',
}));
return await getBasicUsers(safeIds);
},

async 'notifications.markAsRead'(notificationId: unknown) {
Expand Down Expand Up @@ -283,5 +292,79 @@ if (Meteor.isServer) {
const result = await Notifications.removeAsync({ _id: { $in: removableIds }, userId: this.userId });
return { success: true as const, deletedCount: result };
},

async 'notifications.getTeamInvitePreview'(notificationId: unknown) {
if (!this.userId) throw new Meteor.Error('not-authorized');
const targetId = idStr(notificationId);
if (!targetId) throw new Meteor.Error('invalid-argument', 'notificationId is required');

const notifications = await Notifications.find({ userId: this.userId }).fetchAsync();
const invite = notifications.find((n) => idStr(n._id) === targetId);
if (!invite) throw new Meteor.Error('not-found', 'Invite not found');

const data = (invite.data ?? {}) as Record<string, unknown>;
if (data.type !== 'team-invite') {
throw new Meteor.Error('bad-request', 'Notification is not a team invite');
}

const teamId = typeof data.teamId === 'string' ? data.teamId : '';
const inviterId = typeof data.inviterId === 'string' ? data.inviterId : '';
if (!teamId) throw new Meteor.Error('bad-request', 'Invalid invite data');

const team = await Teams.findOneAsync(teamId);
if (!team) throw new Meteor.Error('not-found', 'Team not found');

const memberIds = Array.from(new Set(team.members));
const adminIds = Array.from(new Set(team.admins));
const allIds = Array.from(new Set([...memberIds, ...adminIds]));
const users = await getBasicUsers(allIds);
const userMap = new Map(users.map((u) => [u.id, u]));
const inviter = userMap.get(inviterId) ?? null;

return {
notificationId: targetId,
teamId: team._id!,
teamName: team.name,
teamDescription: team.description ?? '',
inviter,
members: memberIds.map((id) => userMap.get(id) ?? { id, name: 'Unknown', email: '' }),
admins: adminIds.map((id) => userMap.get(id) ?? { id, name: 'Unknown', email: '' }),
alreadyMember: team.members.includes(this.userId),
};
},

async 'notifications.respondToTeamInvite'(fields: { notificationId: unknown; action: 'join' | 'ignore' }) {
if (!this.userId) throw new Meteor.Error('not-authorized');
const notificationId = idStr(fields?.notificationId);
const action = fields?.action;
if (!notificationId || (action !== 'join' && action !== 'ignore')) {
throw new Meteor.Error('invalid-argument', 'Invalid invite response');
}

const notifications = await Notifications.find({ userId: this.userId }).fetchAsync();
const invite = notifications.find((n) => idStr(n._id) === notificationId);
if (!invite) throw new Meteor.Error('not-found', 'Invite not found');

const data = (invite.data ?? {}) as Record<string, unknown>;
if (data.type !== 'team-invite') throw new Meteor.Error('bad-request', 'Notification is not a team invite');

if (action === 'join') {
const teamCode = typeof data.teamCode === 'string' ? data.teamCode : '';
const teamId = typeof data.teamId === 'string' ? data.teamId : '';
if (!teamCode && !teamId) throw new Meteor.Error('bad-request', 'Invalid invite data');

const team = teamCode
? await Teams.findOneAsync({ code: teamCode })
: await Teams.findOneAsync(teamId);

if (!team) throw new Meteor.Error('not-found', 'Team not found');
if (!team.members.includes(this.userId)) {
await Teams.updateAsync(team._id!, { $push: { members: this.userId } });
}
}

await Notifications.removeAsync({ _id: invite._id, userId: this.userId });
return { success: true as const };
},
});
}
Loading