fiddy/apps/web/features/groups/components/use-group-settings-view-model.ts

405 lines
15 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { groupsDelete, groupsRename } from "@/lib/client/groups";
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
import useGroupMembers from "@/features/groups/hooks/use-group-members";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useTags from "@/features/tags/hooks/use-tags";
import type {
AuditFilterKey,
ConfirmInviteDeleteTarget,
ConfirmUserTarget,
InviteMaxUses,
JoinPolicy
} from "@/features/groups/components/group-settings.types";
import { eventCategory, formatInviteExpiry, isInviteExpired } from "@/features/groups/components/group-settings.utils";
import { useGroupsContext } from "@/hooks/groups-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
export default function useGroupSettingsViewModel(groupId: number) {
const router = useRouter();
const { groups, activeGroupId, setActiveGroup } = useGroupsContext();
const { notify } = useNotificationsContext();
const group = useMemo(() => groups.find(item => item.id === groupId) || null, [groups, groupId]);
const role = group?.role;
const isAdmin = role === "GROUP_ADMIN" || role === "GROUP_OWNER";
const isOwner = role === "GROUP_OWNER";
const canViewAudit = isAdmin;
const hasSyncedActiveRef = useRef(false);
const lastGroupIdRef = useRef(groupId);
useEffect(() => {
if (lastGroupIdRef.current !== groupId) {
lastGroupIdRef.current = groupId;
hasSyncedActiveRef.current = false;
}
if (!groupId || hasSyncedActiveRef.current) return;
if (activeGroupId !== groupId) setActiveGroup(groupId);
hasSyncedActiveRef.current = true;
}, [activeGroupId, groupId, setActiveGroup]);
const { tags, addTags, removeTag } = useTags(activeGroupId === groupId ? groupId : null);
const { settings, updateSettings } = useGroupSettings(activeGroupId === groupId ? groupId : null);
const { members, requests, currentUserId, approve, deny, promote, demote, kick, transferOwner, leave } = useGroupMembers(activeGroupId === groupId ? groupId : null);
const { links, create: createInvite, revoke: revokeInvite, revive: reviveInvite, remove: deleteInvite } = useGroupInvites(activeGroupId === groupId ? groupId : null);
const { events } = useGroupAudit(activeGroupId === groupId && canViewAudit ? groupId : null);
const [pendingTags, setPendingTags] = useState<string[]>([]);
const [toggleRemoveTags, setToggleRemoveTags] = useState<string[]>([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [renameValue, setRenameValue] = useState(group?.name || "");
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [confirmRenameOpen, setConfirmRenameOpen] = useState(false);
const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false);
const [confirmKick, setConfirmKick] = useState<ConfirmUserTarget | null>(null);
const [confirmTransfer, setConfirmTransfer] = useState<ConfirmUserTarget | null>(null);
const [confirmDeleteGroupOpen, setConfirmDeleteGroupOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [tagModalOpen, setTagModalOpen] = useState(false);
const [showAllTags, setShowAllTags] = useState(false);
const [inviteTtlDays, setInviteTtlDays] = useState(7);
const [inviteMaxUses, setInviteMaxUses] = useState<InviteMaxUses>("UNLIMITED");
const [auditOpen, setAuditOpen] = useState(false);
const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<ConfirmInviteDeleteTarget | null>(null);
const [auditFilters, setAuditFilters] = useState<AuditFilterKey[]>(["members", "tags", "settings", "entries"]);
const [auditQuery, setAuditQuery] = useState("");
const [auditFrom, setAuditFrom] = useState("");
const [auditTo, setAuditTo] = useState("");
const [auditLimit, setAuditLimit] = useState(10);
const [localAllowMemberTagManage, setLocalAllowMemberTagManage] = useState(settings.allowMemberTagManage);
const [localJoinPolicy, setLocalJoinPolicy] = useState<JoinPolicy>(settings.joinPolicy);
const canManageTags = role === "GROUP_ADMIN" || role === "GROUP_OWNER" || localAllowMemberTagManage;
const adminCount = members.filter(member => member.role === "GROUP_ADMIN" || member.role === "GROUP_OWNER").length;
const isLastAdmin = isAdmin && adminCount <= 1;
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
setRenameValue(group?.name || "");
}, [group?.name]);
useEffect(() => {
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
setLocalJoinPolicy(settings.joinPolicy);
}, [settings.allowMemberTagManage, settings.joinPolicy]);
const handleCloseRenameModal = useCallback(() => {
setRenameModalOpen(false);
setRenameValue(group?.name || "");
}, [group?.name]);
async function handleSaveTags() {
if (!pendingTags.length) return;
const ok = await addTags(pendingTags);
if (ok) {
notify({ title: "Tags updated", message: pendingTags.join(", "), tone: "success" });
setPendingTags([]);
}
}
async function handleConfirmDelete() {
if (!toggleRemoveTags.length) return;
await Promise.all(toggleRemoveTags.map(tag => removeTag(tag)));
notify({ title: "Tags removed", message: toggleRemoveTags.join(", "), tone: "danger" });
setToggleRemoveTags([]);
}
async function handleToggleAllowMembers(value: boolean) {
setLocalAllowMemberTagManage(value);
const ok = await updateSettings(value, localJoinPolicy);
if (ok) {
notify({ title: "Tag permissions updated" });
return;
}
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
notify({ title: "Update failed", message: "Could not save tag permissions.", tone: "danger" });
}
async function handleJoinPolicyChange(nextPolicy: JoinPolicy) {
setLocalJoinPolicy(nextPolicy);
const ok = await updateSettings(localAllowMemberTagManage, nextPolicy);
if (ok) {
notify({ title: "Join policy updated" });
return;
}
setLocalJoinPolicy(settings.joinPolicy);
notify({ title: "Update failed", message: "Could not save join policy.", tone: "danger" });
}
function handleOpenRenameModal() {
setRenameValue(group?.name || "");
setRenameModalOpen(true);
}
async function handleRenameGroup() {
const name = renameValue.trim();
if (!name) return;
const result = await groupsRename({ name });
if ("error" in result) {
notify({ title: "Rename failed", message: result.error.message, tone: "danger" });
return;
}
notify({ title: "Group renamed", message: name });
setConfirmRenameOpen(false);
setRenameModalOpen(false);
}
async function handleRoleChange(targetUserId: number, nextRole: "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER") {
if (!isAdmin) return;
const member = members.find(item => item.userId === targetUserId);
if (!member) return;
if (member.role === nextRole) return;
if (nextRole === "GROUP_OWNER") {
if (!isOwner) return;
await transferOwner(targetUserId);
return;
}
if (nextRole === "GROUP_ADMIN") {
await promote(targetUserId);
return;
}
await demote(targetUserId);
}
function getInviteLinkUrl(token: string) {
if (typeof window === "undefined") return token;
return `${window.location.origin}/invite/${token}`;
}
async function handleCopyInvite(token: string) {
try {
await navigator.clipboard.writeText(getInviteLinkUrl(token));
notify({ title: "Invite link copied", message: "Link copied to clipboard" });
} catch {
notify({ title: "Copy failed", tone: "danger" });
}
}
const memberLookup = useMemo(() => {
return new Map(members.map(member => [member.userId, { name: member.displayName || member.email, email: member.email }]));
}, [members]);
function getMemberLabel(userId?: number | null) {
if (!userId) return "System";
const member = memberLookup.get(userId);
return member?.name || `User ${userId}`;
}
const filteredEvents = events.filter(event => {
const category = eventCategory(event.eventType);
if (!auditFilters.includes(category)) return false;
if (auditQuery) {
const query = auditQuery.toLowerCase();
const actor = event.actorUserId ? memberLookup.get(event.actorUserId) : null;
const haystack = `${event.eventType} ${event.actorUserId ?? ""} ${actor?.name || ""} ${actor?.email || ""} ${event.requestId}`.toLowerCase();
if (!haystack.includes(query)) return false;
}
if (auditFrom) {
const from = new Date(auditFrom).getTime();
if (!Number.isNaN(from) && new Date(event.createdAt).getTime() < from) return false;
}
if (auditTo) {
const to = new Date(auditTo).getTime();
if (!Number.isNaN(to) && new Date(event.createdAt).getTime() > to + 24 * 60 * 60 * 1000 - 1) return false;
}
return true;
});
const todayKey = new Date().toISOString().slice(0, 10);
const logsToday = events.filter(event => event.createdAt?.slice(0, 10) === todayKey).length;
const mostActiveUser = (() => {
const counts = new Map<number, number>();
for (const event of events) {
if (!event.actorUserId) continue;
counts.set(event.actorUserId, (counts.get(event.actorUserId) ?? 0) + 1);
}
let top: number | null = null;
let topCount = 0;
counts.forEach((count, userId) => {
if (count > topCount) {
top = userId;
topCount = count;
}
});
if (!top) return null;
const member = memberLookup.get(top);
const name = member?.name || `User ${top}`;
const searchValue = member?.name || member?.email || String(top);
return { userId: top, count: topCount, name, searchValue };
})();
const mostActiveCount = mostActiveUser?.count ?? 0;
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
const hasMoreTags = tags.length > 5;
const tagsScrollable = showAllTags && tags.length > 15;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
async function handleDeleteGroup() {
const result = await groupsDelete();
if ("error" in result) {
notify({ title: "Delete failed", message: result.error.message, tone: "danger" });
return;
}
setConfirmDeleteGroupOpen(false);
setDeleteConfirmText("");
notify({ title: "Group deleted", tone: "danger" });
router.push("/");
}
async function handleConfirmDeleteInvite() {
if (!confirmDeleteInvite) return;
const target = confirmDeleteInvite;
setConfirmDeleteInvite(null);
const ok = await deleteInvite(target.id);
if (ok) notify({ title: "Invite deleted", tone: "danger" });
}
async function handleConfirmLeaveGroup() {
setConfirmLeaveOpen(false);
const ok = await leave();
if (ok) {
notify({ title: "Left group" });
router.push("/");
}
}
async function handleConfirmKickMember() {
if (!confirmKick) return;
const target = confirmKick;
setConfirmKick(null);
const ok = await kick(target.userId);
if (ok) notify({ title: "Member removed", message: target.name, tone: "danger" });
}
async function handleConfirmTransferOwnership() {
if (!confirmTransfer) return;
const target = confirmTransfer;
setConfirmTransfer(null);
const ok = await transferOwner(target.userId);
if (ok) notify({ title: "Ownership transferred", message: target.name });
}
return {
group,
role,
isAdmin,
isOwner,
canViewAudit,
canManageTags,
isLastAdmin,
memberCount,
showLeaveGroup,
tags,
members,
requests,
currentUserId,
links,
events,
filteredEvents,
logsToday,
mostActiveUser,
mostActiveCount,
visibleTags,
hasMoreTags,
tagsScrollable,
pendingTags,
setPendingTags,
toggleRemoveTags,
setToggleRemoveTags,
confirmDeleteOpen,
setConfirmDeleteOpen,
renameValue,
setRenameValue,
renameModalOpen,
setRenameModalOpen,
renameDirty,
confirmRenameOpen,
setConfirmRenameOpen,
confirmLeaveOpen,
setConfirmLeaveOpen,
confirmKick,
setConfirmKick,
confirmTransfer,
setConfirmTransfer,
confirmDeleteGroupOpen,
setConfirmDeleteGroupOpen,
deleteConfirmText,
setDeleteConfirmText,
tagModalOpen,
setTagModalOpen,
showAllTags,
setShowAllTags,
inviteTtlDays,
setInviteTtlDays,
inviteMaxUses,
setInviteMaxUses,
auditOpen,
setAuditOpen,
confirmDeleteInvite,
setConfirmDeleteInvite,
auditFilters,
setAuditFilters,
auditQuery,
setAuditQuery,
auditFrom,
setAuditFrom,
auditTo,
setAuditTo,
auditLimit,
setAuditLimit,
localAllowMemberTagManage,
localJoinPolicy,
handleSaveTags,
handleConfirmDelete,
handleToggleAllowMembers,
handleJoinPolicyChange,
handleOpenRenameModal,
handleCloseRenameModal,
handleRenameGroup,
handleRoleChange,
handleCopyInvite,
formatInviteExpiry,
isInviteExpired,
getMemberLabel,
handleDeleteGroup,
handleConfirmDeleteInvite,
handleConfirmLeaveGroup,
handleConfirmKickMember,
handleConfirmTransferOwnership,
approve,
deny,
promote,
demote,
kick,
transferOwner,
leave,
createInvite,
revokeInvite,
reviveInvite,
deleteInvite,
goBack: () => router.push("/")
};
}
export type GroupSettingsViewModelState = ReturnType<typeof useGroupSettingsViewModel>;