[CLAUDE] App+Domain+Infra+Api+FE: Notifications module end-to-end
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
Domain: - Notification entity + NotificationType enum (stable ints) - Nullable RefId cho correlation (contract, user, ...) Infrastructure: - NotificationConfiguration: bảng Notifications, index theo (UserId, ReadAt) - NotificationService: ghi vào DbContext, không SaveChanges (để caller quyết định unit-of-work — đảm bảo atomic với domain mutation) - EF migration AddNotifications Application: - INotificationService (Notify + NotifyMany) - CQRS: ListMyNotifications / GetMyUnreadCount / MarkRead / MarkAllRead Api: - NotificationsController: GET /api/notifications + unread-count + mark-read Integration: - ContractWorkflowService emit notification tới Drafter khi HĐ chuyển phase (skip nếu actor chính là Drafter). Title + type theo phase đích: DaPhatHanh → ContractPublished, TuChoi → ContractRejected, khác → ContractPhaseTransition. FE: - Both NotificationBell (admin + user) dùng /api/notifications thật (thay cho derived-from-inbox MVP trước đó). 30s refetch, click mark-read, 'Đọc hết' bulk action. Foundation sẵn cho SignalR push + email outbox sau này — chỉ cần mở rộng NotificationService mà không đổi caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,57 +1,56 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Bell } from 'lucide-react'
|
import { Bell, CheckCheck } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import type { ContractListItem } from '@/types/contracts'
|
|
||||||
import type { Paged } from '@/types/master'
|
|
||||||
|
|
||||||
type Notification = {
|
type NotificationDto = {
|
||||||
id: string
|
id: string
|
||||||
|
type: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string | null
|
||||||
href: string
|
href: string | null
|
||||||
|
refId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
read: boolean
|
readAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// MVP: surface overdue-SLA contracts as notifications. Future iteration will
|
const fmtWhen = (iso: string) => {
|
||||||
// replace with a dedicated notifications endpoint fed by domain events + SignalR.
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
function useOverdueNotifications() {
|
const mins = Math.floor(diff / 60_000)
|
||||||
return useQuery({
|
if (mins < 1) return 'vừa xong'
|
||||||
queryKey: ['notifications-overdue'],
|
if (mins < 60) return `${mins}p trước`
|
||||||
queryFn: async (): Promise<Notification[]> => {
|
const hrs = Math.floor(mins / 60)
|
||||||
const { data } = await api.get<Paged<ContractListItem>>('/contracts', {
|
if (hrs < 24) return `${hrs}h trước`
|
||||||
params: { page: 1, pageSize: 50 },
|
const days = Math.floor(hrs / 24)
|
||||||
})
|
return `${days}d trước`
|
||||||
const now = Date.now()
|
|
||||||
return data.items
|
|
||||||
.filter(c => c.slaDeadline && new Date(c.slaDeadline).getTime() < now + 24 * 60 * 60 * 1000)
|
|
||||||
.map(c => {
|
|
||||||
const deadline = c.slaDeadline ? new Date(c.slaDeadline).getTime() : null
|
|
||||||
const overdue = deadline !== null && deadline < now
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
title: `${overdue ? 'Quá hạn SLA' : 'Sắp quá hạn'} — ${c.maHopDong ?? c.tenHopDong ?? 'HĐ chưa có mã'}`,
|
|
||||||
description: `Phase ${c.phase} · NCC ${c.supplierName}`,
|
|
||||||
href: `/contracts/${c.id}`,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
read: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const q = useOverdueNotifications()
|
const qc = useQueryClient()
|
||||||
const items = q.data ?? []
|
|
||||||
const unread = items.filter(n => !n.read).length
|
const list = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: async () => (await api.get<NotificationDto[]>('/notifications', { params: { limit: 20 } })).data,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = list.data ?? []
|
||||||
|
const unread = items.filter(n => !n.readAt).length
|
||||||
|
|
||||||
|
const markRead = useMutation({
|
||||||
|
mutationFn: async (id: string) => api.post(`/notifications/${id}/read`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAllRead = useMutation({
|
||||||
|
mutationFn: async () => api.post('/notifications/read-all'),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@ -79,42 +78,50 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
<div className="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||||
<div className="border-b border-slate-100 px-4 py-2.5">
|
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
|
||||||
<div className="text-sm font-semibold text-slate-700">Cảnh báo SLA</div>
|
<div>
|
||||||
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} HĐ cần chú ý` : 'Không có cảnh báo mới'}</div>
|
<div className="text-sm font-semibold text-slate-700">Thông báo</div>
|
||||||
|
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} chưa đọc` : 'Đã đọc hết'}</div>
|
||||||
|
</div>
|
||||||
|
{unread > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
|
disabled={markAllRead.isPending}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-brand-600 transition hover:bg-brand-50"
|
||||||
|
title="Đánh dấu tất cả đã đọc"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
|
Đọc hết
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div className="px-4 py-10 text-center text-xs text-slate-400">Mọi HĐ đang đúng tiến độ.</div>
|
<div className="px-4 py-10 text-center text-xs text-slate-400">Không có thông báo nào.</div>
|
||||||
)}
|
)}
|
||||||
{items.map(n => (
|
{items.map(n => (
|
||||||
<button
|
<button
|
||||||
key={n.id}
|
key={n.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(n.href)
|
if (!n.readAt) markRead.mutate(n.id)
|
||||||
|
if (n.href) navigate(n.href)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block w-full border-b border-slate-50 px-4 py-3 text-left transition hover:bg-slate-50',
|
'block w-full border-b border-slate-50 px-4 py-3 text-left transition hover:bg-slate-50',
|
||||||
!n.read && 'bg-amber-50/40',
|
!n.readAt && 'bg-brand-50/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
||||||
<div className="truncate text-xs text-slate-400">{n.description}</div>
|
{n.description && <div className="truncate text-xs text-slate-400">{n.description}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-[10px] text-slate-400">{fmtWhen(n.createdAt)}</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/contracts')
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
className="block w-full border-t border-slate-100 px-4 py-2 text-center text-xs font-medium text-brand-600 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Xem toàn bộ HĐ →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,53 +1,56 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Bell } from 'lucide-react'
|
import { Bell, CheckCheck } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import type { ContractListItem } from '@/types/contracts'
|
|
||||||
|
|
||||||
type Notification = {
|
type NotificationDto = {
|
||||||
id: string
|
id: string
|
||||||
|
type: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string | null
|
||||||
href: string
|
href: string | null
|
||||||
|
refId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
read: boolean
|
readAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// MVP: derive "notifications" from inbox (items awaiting action). Future iteration
|
const fmtWhen = (iso: string) => {
|
||||||
// will replace with a dedicated endpoint fed by domain events + SignalR.
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
function useDerivedNotifications() {
|
const mins = Math.floor(diff / 60_000)
|
||||||
return useQuery({
|
if (mins < 1) return 'vừa xong'
|
||||||
queryKey: ['notifications-derived'],
|
if (mins < 60) return `${mins}p trước`
|
||||||
queryFn: async (): Promise<Notification[]> => {
|
const hrs = Math.floor(mins / 60)
|
||||||
const { data } = await api.get<ContractListItem[]>('/contracts/inbox')
|
if (hrs < 24) return `${hrs}h trước`
|
||||||
return data.map(c => {
|
const days = Math.floor(hrs / 24)
|
||||||
const deadline = c.slaDeadline ? new Date(c.slaDeadline).getTime() : null
|
return `${days}d trước`
|
||||||
const overdue = deadline !== null && deadline < Date.now()
|
|
||||||
const dueSoon = deadline !== null && !overdue && deadline - Date.now() < 86_400_000
|
|
||||||
const prefix = overdue ? 'Quá hạn SLA — ' : dueSoon ? 'Sắp quá hạn — ' : ''
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
title: `${prefix}${c.maHopDong ?? c.tenHopDong ?? 'HĐ chưa có mã'}`,
|
|
||||||
description: `Phase ${c.phase} · NCC ${c.supplierName}`,
|
|
||||||
href: `/contracts/${c.id}`,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
read: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const q = useDerivedNotifications()
|
const qc = useQueryClient()
|
||||||
const items = q.data ?? []
|
|
||||||
const unread = items.filter(n => !n.read).length
|
const list = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: async () => (await api.get<NotificationDto[]>('/notifications', { params: { limit: 20 } })).data,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = list.data ?? []
|
||||||
|
const unread = items.filter(n => !n.readAt).length
|
||||||
|
|
||||||
|
const markRead = useMutation({
|
||||||
|
mutationFn: async (id: string) => api.post(`/notifications/${id}/read`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAllRead = useMutation({
|
||||||
|
mutationFn: async () => api.post('/notifications/read-all'),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@ -75,9 +78,22 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
<div className="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||||
<div className="border-b border-slate-100 px-4 py-2.5">
|
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
|
||||||
|
<div>
|
||||||
<div className="text-sm font-semibold text-slate-700">Thông báo</div>
|
<div className="text-sm font-semibold text-slate-700">Thông báo</div>
|
||||||
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} mục chưa đọc` : 'Không có mục mới'}</div>
|
<div className="text-xs text-slate-400">{unread > 0 ? `${unread} chưa đọc` : 'Đã đọc hết'}</div>
|
||||||
|
</div>
|
||||||
|
{unread > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
|
disabled={markAllRead.isPending}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-brand-600 transition hover:bg-brand-50"
|
||||||
|
title="Đánh dấu tất cả đã đọc"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
|
Đọc hết
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
@ -87,30 +103,25 @@ export function NotificationBell() {
|
|||||||
<button
|
<button
|
||||||
key={n.id}
|
key={n.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(n.href)
|
if (!n.readAt) markRead.mutate(n.id)
|
||||||
|
if (n.href) navigate(n.href)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block w-full border-b border-slate-50 px-4 py-3 text-left transition hover:bg-slate-50',
|
'block w-full border-b border-slate-50 px-4 py-3 text-left transition hover:bg-slate-50',
|
||||||
!n.read && 'bg-brand-50/30',
|
!n.readAt && 'bg-brand-50/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
<div className="truncate text-sm font-medium text-slate-700">{n.title}</div>
|
||||||
<div className="truncate text-xs text-slate-400">{n.description}</div>
|
{n.description && <div className="truncate text-xs text-slate-400">{n.description}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-[10px] text-slate-400">{fmtWhen(n.createdAt)}</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/inbox')
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
className="block w-full border-t border-slate-100 px-4 py-2 text-center text-xs font-medium text-brand-600 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Xem toàn bộ hộp thư →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Application.Notifications.Dtos;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/notifications")]
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<NotificationDto>>> List(
|
||||||
|
[FromQuery] bool unreadOnly = false,
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new ListMyNotificationsQuery(unreadOnly, limit), ct));
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<ActionResult<int>> UnreadCount(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetMyUnreadCountQuery(), ct));
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/read")]
|
||||||
|
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new MarkNotificationReadCommand(id), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("read-all")]
|
||||||
|
public async Task<ActionResult<int>> MarkAllRead(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new MarkAllNotificationsReadCommand(), ct));
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ using SolutionErp.Domain.Contracts;
|
|||||||
using SolutionErp.Domain.Forms;
|
using SolutionErp.Domain.Forms;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Domain.Master;
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
namespace SolutionErp.Application.Common.Interfaces;
|
namespace SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ public interface IApplicationDbContext
|
|||||||
DbSet<ContractComment> ContractComments { get; }
|
DbSet<ContractComment> ContractComments { get; }
|
||||||
DbSet<ContractAttachment> ContractAttachments { get; }
|
DbSet<ContractAttachment> ContractAttachments { get; }
|
||||||
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
||||||
|
DbSet<Notification> Notifications { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
namespace SolutionErp.Application.Notifications.Dtos;
|
||||||
|
|
||||||
|
public record NotificationDto(
|
||||||
|
Guid Id,
|
||||||
|
int Type,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
string? Href,
|
||||||
|
Guid? RefId,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? ReadAt);
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Notifications;
|
||||||
|
|
||||||
|
// Abstraction for emitting notifications from domain handlers.
|
||||||
|
// Implementation lives in Infrastructure and writes to DbContext (eventual SignalR
|
||||||
|
// push + email will layer on the same method without changing callers).
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
Task NotifyAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task NotifyManyAsync(
|
||||||
|
IEnumerable<Guid> userIds,
|
||||||
|
NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Notifications.Dtos;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Notifications;
|
||||||
|
|
||||||
|
// ========== LIST current-user's notifications ==========
|
||||||
|
|
||||||
|
public record ListMyNotificationsQuery(bool UnreadOnly, int Limit) : IRequest<IReadOnlyList<NotificationDto>>;
|
||||||
|
|
||||||
|
public class ListMyNotificationsQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<ListMyNotificationsQuery, IReadOnlyList<NotificationDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<NotificationDto>> Handle(ListMyNotificationsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var take = request.Limit is > 0 and <= 200 ? request.Limit : 50;
|
||||||
|
|
||||||
|
var q = db.Notifications.AsNoTracking().Where(n => n.UserId == userId);
|
||||||
|
if (request.UnreadOnly) q = q.Where(n => n.ReadAt == null);
|
||||||
|
|
||||||
|
return await q
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Take(take)
|
||||||
|
.Select(n => new NotificationDto(
|
||||||
|
n.Id,
|
||||||
|
(int)n.Type,
|
||||||
|
n.Title,
|
||||||
|
n.Description,
|
||||||
|
n.Href,
|
||||||
|
n.RefId,
|
||||||
|
n.CreatedAt,
|
||||||
|
n.ReadAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UNREAD count ==========
|
||||||
|
|
||||||
|
public record GetMyUnreadCountQuery : IRequest<int>;
|
||||||
|
|
||||||
|
public class GetMyUnreadCountQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<GetMyUnreadCountQuery, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(GetMyUnreadCountQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
return await db.Notifications.CountAsync(n => n.UserId == userId && n.ReadAt == null, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MARK as read ==========
|
||||||
|
|
||||||
|
public record MarkNotificationReadCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class MarkNotificationReadCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IDateTime clock) : IRequestHandler<MarkNotificationReadCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(MarkNotificationReadCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var entity = await db.Notifications.FirstOrDefaultAsync(n => n.Id == request.Id && n.UserId == userId, ct)
|
||||||
|
?? throw new NotFoundException("Notification", request.Id);
|
||||||
|
|
||||||
|
if (entity.ReadAt == null)
|
||||||
|
{
|
||||||
|
entity.ReadAt = clock.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MARK all as read ==========
|
||||||
|
|
||||||
|
public record MarkAllNotificationsReadCommand : IRequest<int>;
|
||||||
|
|
||||||
|
public class MarkAllNotificationsReadCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IDateTime clock) : IRequestHandler<MarkAllNotificationsReadCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(MarkAllNotificationsReadCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var now = clock.UtcNow;
|
||||||
|
var unread = await db.Notifications
|
||||||
|
.Where(n => n.UserId == userId && n.ReadAt == null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var n in unread) n.ReadAt = now;
|
||||||
|
if (unread.Count > 0) await db.SaveChangesAsync(ct);
|
||||||
|
return unread.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Backend/SolutionErp.Domain/Notifications/Notification.cs
Normal file
17
src/Backend/SolutionErp.Domain/Notifications/Notification.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
public class Notification : BaseEntity
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public NotificationType Type { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Href { get; set; }
|
||||||
|
public DateTime? ReadAt { get; set; }
|
||||||
|
|
||||||
|
// Optional correlation to source entity (contract, user, etc.). Interpretation
|
||||||
|
// depends on Type. Kept nullable to avoid a discriminated-union migration.
|
||||||
|
public Guid? RefId { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
namespace SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
// Stable ints — order matters for persistence + analytics. Don't renumber.
|
||||||
|
public enum NotificationType
|
||||||
|
{
|
||||||
|
ContractPhaseTransition = 1,
|
||||||
|
ContractCommentAdded = 2,
|
||||||
|
SlaWarning = 3,
|
||||||
|
SlaOverdue = 4,
|
||||||
|
ContractPublished = 5,
|
||||||
|
ContractRejected = 6,
|
||||||
|
Generic = 99,
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
using SolutionErp.Application.Contracts.Services;
|
using SolutionErp.Application.Contracts.Services;
|
||||||
using SolutionErp.Application.Forms.Services;
|
using SolutionErp.Application.Forms.Services;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
using SolutionErp.Application.Reports.Services;
|
using SolutionErp.Application.Reports.Services;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Infrastructure.Forms;
|
using SolutionErp.Infrastructure.Forms;
|
||||||
@ -30,6 +31,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||||
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
|
||||||
// Phase 3 iteration 2 — SLA auto-approve background service
|
// Phase 3 iteration 2 — SLA auto-approve background service
|
||||||
services.AddHostedService<SlaExpiryJob>();
|
services.AddHostedService<SlaExpiryJob>();
|
||||||
|
|||||||
@ -5,6 +5,7 @@ using SolutionErp.Domain.Contracts;
|
|||||||
using SolutionErp.Domain.Forms;
|
using SolutionErp.Domain.Forms;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Domain.Master;
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Persistence;
|
namespace SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ public class ApplicationDbContext
|
|||||||
public DbSet<ContractComment> ContractComments => Set<ContractComment>();
|
public DbSet<ContractComment> ContractComments => Set<ContractComment>();
|
||||||
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
|
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
|
||||||
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
||||||
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class NotificationConfiguration : IEntityTypeConfiguration<Notification>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Notification> e)
|
||||||
|
{
|
||||||
|
e.ToTable("Notifications");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Type).HasConversion<int>();
|
||||||
|
e.Property(x => x.Title).HasMaxLength(300).IsRequired();
|
||||||
|
e.Property(x => x.Description).HasMaxLength(1000);
|
||||||
|
e.Property(x => x.Href).HasMaxLength(500);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.UserId, x.ReadAt });
|
||||||
|
e.HasIndex(x => x.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
1068
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421082148_AddNotifications.Designer.cs
generated
Normal file
1068
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421082148_AddNotifications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotifications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Href = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
ReadAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
RefId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notifications", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_CreatedAt",
|
||||||
|
table: "Notifications",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_ReadAt",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "ReadAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -879,6 +879,58 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Suppliers", (string)null);
|
b.ToTable("Suppliers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Href")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReadAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RefId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "ReadAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
|
|||||||
@ -2,15 +2,18 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using SolutionErp.Application.Common.Exceptions;
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
using SolutionErp.Application.Contracts.Services;
|
using SolutionErp.Application.Contracts.Services;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
using SolutionErp.Domain.Contracts;
|
using SolutionErp.Domain.Contracts;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Services;
|
namespace SolutionErp.Infrastructure.Services;
|
||||||
|
|
||||||
public class ContractWorkflowService(
|
public class ContractWorkflowService(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
IContractCodeGenerator codeGenerator,
|
IContractCodeGenerator codeGenerator,
|
||||||
IDateTime dateTime) : IContractWorkflowService
|
IDateTime dateTime,
|
||||||
|
INotificationService notifications) : IContractWorkflowService
|
||||||
{
|
{
|
||||||
// Map (from, to) → roles được phép chuyển. Xem docs/workflow-contract.md §5.
|
// Map (from, to) → roles được phép chuyển. Xem docs/workflow-contract.md §5.
|
||||||
// Admin luôn bypass (check trong Handler trước khi gọi service).
|
// Admin luôn bypass (check trong Handler trước khi gọi service).
|
||||||
@ -115,6 +118,31 @@ public class ContractWorkflowService(
|
|||||||
ApprovedAt = dateTime.UtcNow,
|
ApprovedAt = dateTime.UtcNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify the drafter (unless they are the actor or contract has no drafter)
|
||||||
|
if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
|
||||||
|
{
|
||||||
|
var title = targetPhase switch
|
||||||
|
{
|
||||||
|
ContractPhase.DaPhatHanh => $"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
|
||||||
|
ContractPhase.TuChoi => $"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
|
||||||
|
_ => $"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển sang phase mới",
|
||||||
|
};
|
||||||
|
var type = targetPhase switch
|
||||||
|
{
|
||||||
|
ContractPhase.DaPhatHanh => NotificationType.ContractPublished,
|
||||||
|
ContractPhase.TuChoi => NotificationType.ContractRejected,
|
||||||
|
_ => NotificationType.ContractPhaseTransition,
|
||||||
|
};
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
drafterId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||||
|
href: $"/contracts/{contract.Id}",
|
||||||
|
refId: contract.Id,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Services;
|
||||||
|
|
||||||
|
// MVP: writes directly to DbContext. Does NOT call SaveChanges — caller's unit of
|
||||||
|
// work flushes both the domain mutation and the notification atomically.
|
||||||
|
// Future: wrap with SignalR IHubContext push + Outbox for email dispatch.
|
||||||
|
public class NotificationService(IApplicationDbContext db, IDateTime clock) : INotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
db.Notifications.Add(new Notification
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Href = href,
|
||||||
|
RefId = refId,
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyManyAsync(
|
||||||
|
IEnumerable<Guid> userIds,
|
||||||
|
NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = clock.UtcNow;
|
||||||
|
foreach (var userId in userIds.Distinct())
|
||||||
|
{
|
||||||
|
db.Notifications.Add(new Notification
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Href = href,
|
||||||
|
RefId = refId,
|
||||||
|
CreatedAt = now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user