Files
gitmost/apps/client/src/features/share/queries/share-query.ts
claude code agent 227 fdeede003b feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that
sits alongside the untouched /share/... routes.

- New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias),
  page_id nullable ON DELETE SET NULL so the address outlives its target).
- ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard /
  availability / request-time readable-target resolution through the single
  existing share boundary).
- Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301,
  the target is mutable) to the canonical /share/:key/p/:slug page; unknown /
  dangling / no-longer-readable aliases serve the SPA index with no leak.
  'l/:alias' excluded from the global /api prefix.
- Authenticated ShareAliasController (set/remove/availability/for-page).
- Shared ASCII-only normalize/validate util (server + client copies).
- Client: Custom address block in the share modal (live normalize + debounced
  availability + copy + reassign confirmation dialog).
- Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity
  (server jest) + client alias util (vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:28:26 +03:00

255 lines
6.7 KiB
TypeScript

import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShare,
IShareAlias,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
IShareForPage,
IShareInfoInput,
IUpdateShare,
} from "@/features/share/types/share.types.ts";
import {
createShare,
deleteShare,
getShareAliasForPage,
getSharedPageTree,
getShareForPage,
getShareInfo,
getSharePageInfo,
getShares,
removeShareAlias,
setShareAlias,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export function useGetSharesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<ISharedItem>, Error> {
return useQuery({
queryKey: ["share-list", params],
queryFn: () => getShares(params),
placeholderData: keepPreviousData,
});
}
export function useGetShareByIdQuery(
shareId: string,
): UseQueryResult<IShare, Error> {
const query = useQuery({
queryKey: ["share-by-id", shareId],
queryFn: () => getShareInfo(shareId),
enabled: !!shareId,
});
return query;
}
export function useSharePageQuery(
shareInput: Partial<IShareInfoInput>,
): UseQueryResult<ISharedPage, Error> {
const query = useQuery({
queryKey: ["shares", shareInput],
queryFn: () => getSharePageInfo(shareInput),
enabled: !!shareInput.pageId,
});
return query;
}
export function useShareForPageQuery(
pageId: string,
): UseQueryResult<IShareForPage | null, Error> {
const query = useQuery({
queryKey: ["share-for-page", pageId],
// React Query forbids `undefined` as resolved data ("Query data cannot be
// undefined"). When no share exists for the page the endpoint resolves to
// undefined, so normalize the absence to `null`.
queryFn: async () => (await getShareForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,
});
return query;
}
export function useCreateShareMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<any, Error, ICreateShare>({
mutationFn: (data) => createShare(data),
onSuccess: (data) => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({
message: error?.["response"]?.data?.message || t("Failed to share page"),
color: "red",
});
},
});
}
export function useUpdateShareMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<any, Error, IUpdateShare>({
mutationFn: (data) => updateShare(data),
onSuccess: (data) => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-for-page", "share-list"].includes(item.queryKey[0] as string),
});
},
onError: (error, params) => {
if (error?.["status"] === 404) {
queryClient.removeQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
notifications.show({
message: t("Share not found"),
color: "red",
});
return;
}
notifications.show({
message: error?.["response"]?.data?.message || "Share not found",
color: "red",
});
},
});
}
export function useDeleteShareMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareId: string) => deleteShare(shareId),
onSuccess: (data) => {
queryClient.removeQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
queryClient.invalidateQueries({
predicate: (item) =>
["share-list"].includes(item.queryKey[0] as string),
});
notifications.show({ message: t("Share deleted successfully") });
},
onError: (error) => {
if (error?.["status"] === 404) {
queryClient.removeQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
}
notifications.show({
message: error?.["response"]?.data?.message || "Failed to delete share",
color: "red",
});
},
});
}
export function useShareAliasForPageQuery(
pageId: string,
): UseQueryResult<IShareAlias | null, Error> {
return useQuery({
// The endpoint resolves to null when the page has no alias; normalize the
// absence so React Query never sees `undefined`.
queryKey: ["share-alias-for-page", pageId],
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,
});
}
export function useSetShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IShareAlias, Error, ISetShareAlias>({
mutationFn: (data) => setShareAlias(data),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
// A 409 reassign-required is handled inline by the modal (it shows the
// "move address here?" confirmation), so don't surface a generic toast.
if (error?.["status"] === 409) return;
notifications.show({
message:
error?.["response"]?.data?.message || t("Failed to set custom address"),
color: "red",
});
},
});
}
export function useRemoveShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (aliasId) => removeShareAlias(aliasId),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to remove custom address"),
color: "red",
});
},
});
}
export function useGetSharedPageTreeQuery(
shareId: string,
): UseQueryResult<ISharedPageTree, Error> {
return useQuery({
queryKey: ["shared-page-tree", shareId],
queryFn: () => getSharedPageTree(shareId),
enabled: !!shareId,
placeholderData: keepPreviousData,
staleTime: 60 * 60 * 1000,
});
}