diff --git a/imports/features/notifications/NotificationsPage.tsx b/imports/features/notifications/NotificationsPage.tsx index 42a00a1..5fadfab 100644 --- a/imports/features/notifications/NotificationsPage.tsx +++ b/imports/features/notifications/NotificationsPage.tsx @@ -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'; @@ -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; @@ -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(null); + const [inviteError, setInviteError] = useState(null); const hasUnread = useMemo(() => notifications.some((n) => !n.read), [notifications]); const allIds = useMemo(() => notifications.map((n) => idStr(n._id)), [notifications]); @@ -138,13 +163,21 @@ export const NotificationsPage: React.FC = () => { return; } try { + const data = (doc.data ?? {}) as Record; + 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(() => { @@ -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 (
@@ -305,6 +354,65 @@ export const NotificationsPage: React.FC = () => {
)} + + !open && closeInviteModal()} size="lg"> + + Team Invite + + + + {invitePreview && ( +
+
+ Team name + {invitePreview.teamName} +
+
+ Team description + + {invitePreview.teamDescription || 'No team description provided.'} + +
+
+ Invited by + + {invitePreview.inviter + ? `${invitePreview.inviter.name}${invitePreview.inviter.email ? ` (${invitePreview.inviter.email})` : ''}` + : 'Unknown'} + +
+
+ Admins ({invitePreview.admins.length}) + + {invitePreview.admins.map((a) => a.name).join(', ') || 'None'} + +
+
+ Team members ({invitePreview.members.length}) + + {invitePreview.members.map((m) => m.name).join(', ') || 'None'} + +
+ {inviteError && ( + {inviteError} + )} +
+ )} +
+ + + + +
); }; diff --git a/imports/features/teams/api.ts b/imports/features/teams/api.ts index d739947..6275ba5 100644 --- a/imports/features/teams/api.ts +++ b/imports/features/teams/api.ts @@ -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 { + 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 { const team = await Teams.findOneAsync(teamId); if (!team) throw new Meteor.Error('not-found', 'Team not found'); @@ -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); }); @@ -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) { @@ -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; + 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; + 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 }; + }, }); }