fix(tasks): skip audit log on zero-template runs, add POLICY/CLIENT/multi-section test coverage
- runGenerateAndAssign now returns early (no audit log write, no client-name lookup) when no active templates match the resolved designation, mirroring the existing early return for zero matched clients. - Add tests for: zero-template real run writes no audit log; POLICY-level template against an ungrouped policy; CLIENT-level template created via prisma.task.create; and a single client accumulating estimatedTasks from both a group-level and a policy-level template in the same run.
This commit is contained in:
parent
fd38e0c148
commit
06842f9031
2 changed files with 271 additions and 146 deletions
|
|
@ -138,6 +138,121 @@ describe('runGenerateAndAssign — advocate-designation mode', () => {
|
||||||
status: 404,
|
status: 404,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not write an audit log and returns zero counts when no templates match', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
const result = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
expect(mockAuditLogCreate).not.toHaveBeenCalled()
|
||||||
|
expect(result.totalEstimatedTasks).toBe(0)
|
||||||
|
expect(result.tasksCreated).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and counts a POLICY-level task for an ungrouped policy', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-policy',
|
||||||
|
name: 'Review policy renewal',
|
||||||
|
description: null,
|
||||||
|
department: 'Personal Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'POLICY',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
mockPolicyFindMany.mockResolvedValue([
|
||||||
|
{ id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') },
|
||||||
|
])
|
||||||
|
mockTaskCreateMany.mockResolvedValue({ count: 1 })
|
||||||
|
mockTaskFindMany.mockResolvedValue([{ id: 'task-policy-1' }])
|
||||||
|
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 1 })
|
||||||
|
|
||||||
|
const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||||||
|
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
expect(dryRunResult.totalEstimatedTasks).toBe(1)
|
||||||
|
expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
|
||||||
|
expect(mockTaskCreateMany).toHaveBeenCalledTimes(1)
|
||||||
|
expect(realResult.tasksCreated).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and counts a CLIENT-level task via prisma.task.create', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-client',
|
||||||
|
name: 'Send client renewal letter',
|
||||||
|
description: null,
|
||||||
|
department: 'Commercial Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'CLIENT',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
mockClientFindUnique.mockResolvedValue({
|
||||||
|
policyGroups: [{ renewalDate: new Date('2026-08-01') }],
|
||||||
|
policies: [],
|
||||||
|
})
|
||||||
|
mockTaskFindFirst.mockResolvedValue(null)
|
||||||
|
mockTaskCreate.mockResolvedValue({ id: 'task-client-1' })
|
||||||
|
mockTaskAssignmentCreate.mockResolvedValue({})
|
||||||
|
|
||||||
|
const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||||||
|
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
expect(dryRunResult.totalEstimatedTasks).toBe(1)
|
||||||
|
expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
|
||||||
|
expect(mockTaskCreate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockTaskCreateMany).not.toHaveBeenCalled()
|
||||||
|
expect(realResult.tasksCreated).toBe(1)
|
||||||
|
expect(realResult.tasksAssigned).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums estimatedTasks for a client that gets tasks from both group- and policy-level templates', async () => {
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
|
||||||
|
mockClientsForAdvocateDesignation()
|
||||||
|
mockTaskTemplateFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-group',
|
||||||
|
name: 'Prepare loss summary',
|
||||||
|
description: null,
|
||||||
|
department: 'Commercial Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'RENEWAL_GROUP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'template-policy',
|
||||||
|
name: 'Review policy renewal',
|
||||||
|
description: null,
|
||||||
|
department: 'Personal Lines',
|
||||||
|
timing: 'PRE_RENEWAL',
|
||||||
|
daysOffset: -1,
|
||||||
|
defaultPriority: 'NORMAL',
|
||||||
|
level: 'POLICY',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
mockPolicyGroupFindMany.mockResolvedValue([
|
||||||
|
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
|
||||||
|
])
|
||||||
|
mockPolicyFindMany.mockResolvedValue([
|
||||||
|
{ id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
|
||||||
|
|
||||||
|
expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 2 }])
|
||||||
|
expect(result.totalEstimatedTasks).toBe(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('parseGenerateAndAssignTarget', () => {
|
describe('parseGenerateAndAssignTarget', () => {
|
||||||
|
|
|
||||||
|
|
@ -140,166 +140,176 @@ export async function runGenerateAndAssign(
|
||||||
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (allTemplates.length > 0) {
|
if (allTemplates.length === 0) {
|
||||||
const groupTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP')
|
return {
|
||||||
const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY')
|
dryRun: options.dryRun,
|
||||||
const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT')
|
clientsFound: resolved.clientIds.length,
|
||||||
|
totalEstimatedTasks: 0,
|
||||||
|
advocateName: resolved.advocateName,
|
||||||
|
clients: [],
|
||||||
|
tasksCreated: 0,
|
||||||
|
tasksAssigned: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const groups = await prisma.policyGroup.findMany({
|
const groupTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP')
|
||||||
where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } },
|
const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY')
|
||||||
})
|
const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT')
|
||||||
|
|
||||||
for (const group of groups) {
|
const groups = await prisma.policyGroup.findMany({
|
||||||
const renewalDate = new Date(group.renewalDate)
|
where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } },
|
||||||
const tasksToCreate = groupTemplates
|
})
|
||||||
.map((template) => {
|
|
||||||
const dueDate = new Date(renewalDate)
|
for (const group of groups) {
|
||||||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
const renewalDate = new Date(group.renewalDate)
|
||||||
return {
|
const tasksToCreate = groupTemplates
|
||||||
|
.map((template) => {
|
||||||
|
const dueDate = new Date(renewalDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||||||
|
return {
|
||||||
|
title: template.name,
|
||||||
|
description: template.description,
|
||||||
|
department: template.department,
|
||||||
|
timing: template.timing,
|
||||||
|
daysOffset: template.daysOffset,
|
||||||
|
dueDate,
|
||||||
|
status: 'NOT_STARTED' as const,
|
||||||
|
priority: template.defaultPriority,
|
||||||
|
clientId: group.clientId,
|
||||||
|
policyGroupId: group.id,
|
||||||
|
templateId: template.id,
|
||||||
|
createdBy: options.actorUserId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((task) => isRelevantDueDate(task.dueDate))
|
||||||
|
|
||||||
|
if (tasksToCreate.length === 0) continue
|
||||||
|
estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length)
|
||||||
|
if (options.dryRun) continue
|
||||||
|
|
||||||
|
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||||||
|
tasksCreated += created.count
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const newTasks = await prisma.task.findMany({
|
||||||
|
where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (newTasks.length > 0) {
|
||||||
|
const assigned = await prisma.taskAssignment.createMany({
|
||||||
|
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
tasksAssigned += assigned.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const policies = await prisma.policy.findMany({
|
||||||
|
where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
const anchorDate = new Date(policy.expirationDate)
|
||||||
|
anchorDate.setDate(anchorDate.getDate() + 1)
|
||||||
|
const tasksToCreate = policyTemplates
|
||||||
|
.map((template) => {
|
||||||
|
const dueDate = new Date(anchorDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||||||
|
return {
|
||||||
|
title: template.name,
|
||||||
|
description: template.description,
|
||||||
|
department: template.department,
|
||||||
|
timing: template.timing,
|
||||||
|
daysOffset: template.daysOffset,
|
||||||
|
dueDate,
|
||||||
|
status: 'NOT_STARTED' as const,
|
||||||
|
priority: template.defaultPriority,
|
||||||
|
clientId: policy.clientId,
|
||||||
|
policyId: policy.id,
|
||||||
|
templateId: template.id,
|
||||||
|
createdBy: options.actorUserId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((task) => isRelevantDueDate(task.dueDate))
|
||||||
|
|
||||||
|
if (tasksToCreate.length === 0) continue
|
||||||
|
estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length)
|
||||||
|
if (options.dryRun) continue
|
||||||
|
|
||||||
|
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||||||
|
tasksCreated += created.count
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const newTasks = await prisma.task.findMany({
|
||||||
|
where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (newTasks.length > 0) {
|
||||||
|
const assigned = await prisma.taskAssignment.createMany({
|
||||||
|
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
tasksAssigned += assigned.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientTemplates.length > 0) {
|
||||||
|
for (const clientId of resolved.clientIds) {
|
||||||
|
const clientRecord = await prisma.client.findUnique({
|
||||||
|
where: { id: clientId },
|
||||||
|
select: {
|
||||||
|
policyGroups: { select: { renewalDate: true } },
|
||||||
|
policies: { where: { policyGroupId: null }, select: { expirationDate: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!clientRecord) continue
|
||||||
|
|
||||||
|
const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
|
||||||
|
const policyDates = clientRecord.policies.map((p) => {
|
||||||
|
const d = new Date(p.expirationDate)
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
|
return d.getTime()
|
||||||
|
})
|
||||||
|
const allDates = [...groupDates, ...policyDates]
|
||||||
|
if (allDates.length === 0) continue
|
||||||
|
const anchorDate = new Date(Math.min(...allDates))
|
||||||
|
|
||||||
|
for (const template of clientTemplates) {
|
||||||
|
const existing = await prisma.task.findFirst({
|
||||||
|
where: { clientId, templateId: template.id, policyId: null, policyGroupId: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (existing) continue
|
||||||
|
|
||||||
|
const dueDate = new Date(anchorDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||||||
|
if (!isRelevantDueDate(dueDate)) continue
|
||||||
|
|
||||||
|
estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1)
|
||||||
|
if (options.dryRun) continue
|
||||||
|
|
||||||
|
const createdTask = await prisma.task.create({
|
||||||
|
data: {
|
||||||
title: template.name,
|
title: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
department: template.department,
|
department: template.department,
|
||||||
timing: template.timing,
|
timing: template.timing,
|
||||||
daysOffset: template.daysOffset,
|
daysOffset: template.daysOffset,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: 'NOT_STARTED' as const,
|
status: 'NOT_STARTED',
|
||||||
priority: template.defaultPriority,
|
priority: template.defaultPriority,
|
||||||
clientId: group.clientId,
|
clientId,
|
||||||
policyGroupId: group.id,
|
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
createdBy: options.actorUserId,
|
createdBy: options.actorUserId,
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((task) => isRelevantDueDate(task.dueDate))
|
|
||||||
|
|
||||||
if (tasksToCreate.length === 0) continue
|
|
||||||
estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length)
|
|
||||||
if (options.dryRun) continue
|
|
||||||
|
|
||||||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
|
||||||
tasksCreated += created.count
|
|
||||||
|
|
||||||
if (created.count > 0) {
|
|
||||||
const newTasks = await prisma.task.findMany({
|
|
||||||
where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (newTasks.length > 0) {
|
|
||||||
const assigned = await prisma.taskAssignment.createMany({
|
|
||||||
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
tasksAssigned += assigned.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const policies = await prisma.policy.findMany({
|
|
||||||
where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const policy of policies) {
|
|
||||||
const anchorDate = new Date(policy.expirationDate)
|
|
||||||
anchorDate.setDate(anchorDate.getDate() + 1)
|
|
||||||
const tasksToCreate = policyTemplates
|
|
||||||
.map((template) => {
|
|
||||||
const dueDate = new Date(anchorDate)
|
|
||||||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
|
||||||
return {
|
|
||||||
title: template.name,
|
|
||||||
description: template.description,
|
|
||||||
department: template.department,
|
|
||||||
timing: template.timing,
|
|
||||||
daysOffset: template.daysOffset,
|
|
||||||
dueDate,
|
|
||||||
status: 'NOT_STARTED' as const,
|
|
||||||
priority: template.defaultPriority,
|
|
||||||
clientId: policy.clientId,
|
|
||||||
policyId: policy.id,
|
|
||||||
templateId: template.id,
|
|
||||||
createdBy: options.actorUserId,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((task) => isRelevantDueDate(task.dueDate))
|
|
||||||
|
|
||||||
if (tasksToCreate.length === 0) continue
|
|
||||||
estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length)
|
|
||||||
if (options.dryRun) continue
|
|
||||||
|
|
||||||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
|
||||||
tasksCreated += created.count
|
|
||||||
|
|
||||||
if (created.count > 0) {
|
|
||||||
const newTasks = await prisma.task.findMany({
|
|
||||||
where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (newTasks.length > 0) {
|
|
||||||
const assigned = await prisma.taskAssignment.createMany({
|
|
||||||
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
tasksAssigned += assigned.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientTemplates.length > 0) {
|
|
||||||
for (const clientId of resolved.clientIds) {
|
|
||||||
const clientRecord = await prisma.client.findUnique({
|
|
||||||
where: { id: clientId },
|
|
||||||
select: {
|
|
||||||
policyGroups: { select: { renewalDate: true } },
|
|
||||||
policies: { where: { policyGroupId: null }, select: { expirationDate: true } },
|
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
})
|
})
|
||||||
if (!clientRecord) continue
|
tasksCreated++
|
||||||
|
|
||||||
const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
|
await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } })
|
||||||
const policyDates = clientRecord.policies.map((p) => {
|
tasksAssigned++
|
||||||
const d = new Date(p.expirationDate)
|
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
return d.getTime()
|
|
||||||
})
|
|
||||||
const allDates = [...groupDates, ...policyDates]
|
|
||||||
if (allDates.length === 0) continue
|
|
||||||
const anchorDate = new Date(Math.min(...allDates))
|
|
||||||
|
|
||||||
for (const template of clientTemplates) {
|
|
||||||
const existing = await prisma.task.findFirst({
|
|
||||||
where: { clientId, templateId: template.id, policyId: null, policyGroupId: null },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (existing) continue
|
|
||||||
|
|
||||||
const dueDate = new Date(anchorDate)
|
|
||||||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
|
||||||
if (!isRelevantDueDate(dueDate)) continue
|
|
||||||
|
|
||||||
estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1)
|
|
||||||
if (options.dryRun) continue
|
|
||||||
|
|
||||||
const createdTask = await prisma.task.create({
|
|
||||||
data: {
|
|
||||||
title: template.name,
|
|
||||||
description: template.description,
|
|
||||||
department: template.department,
|
|
||||||
timing: template.timing,
|
|
||||||
daysOffset: template.daysOffset,
|
|
||||||
dueDate,
|
|
||||||
status: 'NOT_STARTED',
|
|
||||||
priority: template.defaultPriority,
|
|
||||||
clientId,
|
|
||||||
templateId: template.id,
|
|
||||||
createdBy: options.actorUserId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
tasksCreated++
|
|
||||||
|
|
||||||
await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } })
|
|
||||||
tasksAssigned++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue