fix(notifications): normalize action links and request timestamps

This commit is contained in:
lizerk 2026-04-13 04:02:24 +03:00
parent af423d8d0a
commit 993e26e8ed
10 changed files with 191 additions and 18 deletions

View File

@ -245,15 +245,15 @@ def _notify_comment(message):
if model_name == 'Post':
object_author = getattr(context_obj, 'author', None)
object_url = f"/feed/#{context_obj.id}"
object_url = f"/?post={context_obj.id}"
object_type = "публикации"
elif model_name == 'Document':
object_author = getattr(context_obj, 'uploaded_by', None)
object_url = f"/documents/{context_obj.id}/"
object_url = f"/documents?document={context_obj.id}"
object_type = "документа"
elif hasattr(context_obj, 'employee'): # Request
object_author = getattr(context_obj, 'employee', None)
object_url = f"/requests/{context_obj.id}/"
object_url = f"/requests?request={context_obj.id}"
object_type = "заявки"
# 1. Уведомляем автора объекта

View File

@ -84,7 +84,7 @@ class ActionURLs:
TODO: реализовать прямые ссылки на конкретные документы
"""
return f'/documents?id={document_id}'
return f'/documents?document={document_id}'
# ===== Константы =====

View File

@ -17,6 +17,7 @@ class NotificationItemSerializer(serializers.Serializer):
category = serializers.CharField()
action_url = serializers.CharField(
required=False, allow_blank=True, allow_null=True)
data = serializers.JSONField(required=False, allow_null=True)
verb = serializers.CharField()
description = serializers.CharField()
actor = NotificationActorSerializer(required=False, allow_null=True)

View File

@ -132,6 +132,7 @@ def get_notifications(request):
'created_at': n.timestamp.isoformat(),
'category': n.verb,
'action_url': n.action_url,
'data': n.data,
# Дополнительные поля для обратной совместимости
'verb': n.verb,
'description': n.description,

View File

@ -168,4 +168,4 @@ class ActionURLs:
@staticmethod
def request_detail(request_id: int) -> str:
"""URL детальной страницы заявления."""
return f'/requests/{request_id}'
return f'/requests?request={request_id}'

View File

@ -13,7 +13,7 @@ import type { User } from "@/types/api";
import { ArrowUpDown, Ban, ChevronDown, ChevronRight, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react";
import dynamic from "next/dynamic";
import { SearchableSelectMulti } from "@/components/shared/SearchableSelect";
import { formatDate, displayUserName, userProfileLink } from "@/lib/shared";
import { formatDate, formatDateTime, displayUserName, userProfileLink } from "@/lib/shared";
import {
useRequestsPage,
statusMeta,
@ -418,7 +418,7 @@ function RequestsPageContent() {
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-4"><div className="space-y-3"><div className="flex flex-wrap items-center gap-2"><span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">{dtl}</span><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${ds.className}`}>{ds.label}</span></div><h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">{dr.display_title || dr.title || "Без заголовка"}</h2>{(dr.comment || dr.description) ? <div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">{dr.comment || dr.description}</div> : <p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>}</div></div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p><div className="mt-3 space-y-2"><div><p className="app-text-muted text-xs">Период</p><p className="mt-1 text-sm text-[var(--foreground)]">{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}</p></div><div><p className="app-text-muted text-xs">Создано</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDate(dr.created_at) || "—"}</p></div><div><p className="app-text-muted text-xs">Обновлено</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDate(dr.updated_at) || "—"}</p></div></div></div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p><div className="mt-3 space-y-2"><div><p className="app-text-muted text-xs">Период</p><p className="mt-1 text-sm text-[var(--foreground)]">{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}</p></div><div><p className="app-text-muted text-xs">Создано</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDateTime(dr.created_at) || "—"}</p></div><div><p className="app-text-muted text-xs">Обновлено</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDateTime(dr.updated_at) || "—"}</p></div></div></div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p><div className="mt-3 space-y-3"><div><p className="app-text-muted text-xs">Автор</p><div className="mt-2 flex flex-wrap gap-2">{da ? renderUserBadge(da as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div><div><p className="app-text-muted text-xs">Решающий</p><div className="mt-2 flex flex-wrap gap-2">{dap ? renderUserBadge(dap as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div></div></div>
</div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>{(dr.departments || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.departments || []).map((id) => <span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">{h.departmentNameMap.get(Number(id)) || `Отдел #${id}`}</span>)}</div> : <p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>}</div>

View File

@ -17,6 +17,7 @@ export type NotificationItem = {
timestamp?: string;
created_at?: string;
action_url?: string;
data?: Record<string, unknown> | null;
[key: string]: unknown;
};

View File

@ -5,20 +5,177 @@ type NotificationNavigationLike = {
data?: Record<string, unknown> | null;
};
function parseNumericId(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function buildUrl(pathname: string, params: Record<string, number | null>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== null) {
searchParams.set(key, String(value));
}
});
const query = searchParams.toString();
return query ? `${pathname}?${query}` : pathname;
}
function normalizeRawActionUrl(rawActionUrl: string): string {
try {
const url = new URL(rawActionUrl, "http://localhost");
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return rawActionUrl;
}
}
export function resolveNotificationActionUrl(
notification: NotificationNavigationLike,
): string | null {
const rawRequestId = notification.data?.request_id;
const requestId = typeof rawRequestId === "number"
? rawRequestId
: typeof rawRequestId === "string" && rawRequestId.trim()
? Number(rawRequestId)
: null;
const data = notification.data && typeof notification.data === "object"
? notification.data
: null;
const categoryHint = `${notification.verb || ""} ${notification.category || ""}`.toLowerCase();
if (requestId && Number.isFinite(requestId) && categoryHint.includes("procurement")) {
return `/procurement?request=${requestId}`;
const requestId = parseNumericId(data?.request_id);
const documentId = parseNumericId(data?.document_id);
const eventId = parseNumericId(data?.event_id);
const postId = parseNumericId(data?.post_id);
const chatId = parseNumericId(data?.chat_id);
const messageId = parseNumericId(data?.message_id);
const objectId = parseNumericId(data?.object_id);
const objectType = typeof data?.object_type === "string"
? data.object_type.toLowerCase()
: "";
const categoryHint = [
notification.verb || "",
notification.category || "",
objectType,
].join(" ").toLowerCase();
if (requestId !== null && categoryHint.includes("procurement")) {
return buildUrl("/procurement", { request: requestId });
}
return notification.action_url || null;
if (
requestId !== null && (
categoryHint.includes("request")
|| objectType === "request"
)
) {
return buildUrl("/requests", { request: requestId });
}
if (
documentId !== null && (
categoryHint.includes("document")
|| objectType === "document"
)
) {
return buildUrl("/documents", { document: documentId });
}
if (eventId !== null && categoryHint.includes("event")) {
return buildUrl("/calendar", { event: eventId });
}
if (
postId !== null && (
categoryHint.includes("feed")
|| categoryHint.includes("post")
|| objectType === "post"
)
) {
return buildUrl("/", { post: postId });
}
if (chatId !== null) {
return buildUrl(`/messages/${chatId}`, { message: messageId });
}
if (objectId !== null) {
if (objectType === "post") {
return buildUrl("/", { post: objectId });
}
if (objectType === "document") {
return buildUrl("/documents", { document: objectId });
}
if (objectType === "request") {
return buildUrl("/requests", { request: objectId });
}
}
const rawActionUrl = typeof notification.action_url === "string"
? normalizeRawActionUrl(notification.action_url.trim())
: "";
if (rawActionUrl) {
const url = new URL(rawActionUrl, "http://localhost");
if (url.pathname === "/procurement") {
const rawProcurementRequestId = parseNumericId(url.searchParams.get("request"));
return buildUrl("/procurement", { request: rawProcurementRequestId });
}
if (url.pathname === "/requests") {
const rawLinkedRequestId = parseNumericId(
url.searchParams.get("request") ?? url.searchParams.get("id"),
);
return buildUrl("/requests", { request: rawLinkedRequestId });
}
if (url.pathname === "/documents") {
const rawLinkedDocumentId = parseNumericId(
url.searchParams.get("document") ?? url.searchParams.get("id"),
);
return buildUrl("/documents", { document: rawLinkedDocumentId });
}
if (url.pathname === "/calendar") {
const rawLinkedEventId = parseNumericId(url.searchParams.get("event"));
return buildUrl("/calendar", { event: rawLinkedEventId });
}
if (url.pathname === "/") {
const rawLinkedPostId = parseNumericId(url.searchParams.get("post"));
return buildUrl("/", { post: rawLinkedPostId });
}
}
const requestMatch = rawActionUrl.match(/^\/requests\/(\d+)\/?(?:[#?].*)?$/);
if (requestMatch) {
return buildUrl("/requests", { request: Number(requestMatch[1]) });
}
const documentMatch = rawActionUrl.match(/^\/documents\/(\d+)\/?(?:[#?].*)?$/);
if (documentMatch) {
return buildUrl("/documents", { document: Number(documentMatch[1]) });
}
const calendarMatch = rawActionUrl.match(/^\/calendar\/events\/(\d+)\/?(?:[#?].*)?$/);
if (calendarMatch) {
return buildUrl("/calendar", { event: Number(calendarMatch[1]) });
}
const feedPathMatch = rawActionUrl.match(/^\/feed\/(?:posts?\/)?(\d+)\/?(?:[#?].*)?$/);
if (feedPathMatch) {
return buildUrl("/", { post: Number(feedPathMatch[1]) });
}
const feedHashMatch = rawActionUrl.match(/^\/feed\/?#(\d+)$/);
if (feedHashMatch) {
return buildUrl("/", { post: Number(feedHashMatch[1]) });
}
return rawActionUrl || null;
}

View File

@ -13,6 +13,19 @@ export function formatDate(value?: string | null): string {
});
}
export function formatDateTime(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function formatMoney(value?: string | number | null): string {
if (value === null || value === undefined || value === "") return "—";
const amount = Number(value);

View File

@ -1,3 +1,3 @@
export { formatDate, formatMoney } from "./formatters";
export { formatDate, formatDateTime, formatMoney } from "./formatters";
export { displayUserName, userProfileLink } from "./userUtils";
export { extractNextPage, loadAllPages } from "./paginationUtils";