feat: JKKP 6/7 PDF generation via pdf-lib, download from incident detail
Also excludes node_modules.nosync from vitest test discovery to fix pre-existing bleed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -18,6 +18,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
|||||||
.select(`
|
.select(`
|
||||||
id, reference_no, incident_type, description, severity, status,
|
id, reference_no, incident_type, description, severity, status,
|
||||||
injury_involved, asset_involved, medical_status, lost_days,
|
injury_involved, asset_involved, medical_status, lost_days,
|
||||||
|
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
|
||||||
reported_at, closed_at,
|
reported_at, closed_at,
|
||||||
sites (id, name),
|
sites (id, name),
|
||||||
zones (id, name),
|
zones (id, name),
|
||||||
@@ -69,6 +70,30 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{Boolean(
|
||||||
|
(incident as { is_fatality?: boolean }).is_fatality ||
|
||||||
|
(incident as { is_serious_bodily_injury?: boolean }).is_serious_bodily_injury ||
|
||||||
|
(incident as { is_dangerous_occurrence?: boolean }).is_dangerous_occurrence ||
|
||||||
|
((incident as { lost_days?: number | null }).lost_days ?? 0) >= 4
|
||||||
|
) && (
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<a
|
||||||
|
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
|
||||||
|
target="_blank"
|
||||||
|
className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
Download JKKP 6 (PDF)
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
|
||||||
|
target="_blank"
|
||||||
|
className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Download JKKP 7 (PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { buildJkkp6Pdf, buildJkkp7Pdf, type JkkpIncident } from '@/lib/pdf/jkkp'
|
||||||
|
import { computeDoshObligation } from '@/lib/incidents/dosh'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const form = request.nextUrl.searchParams.get('form')
|
||||||
|
if (form !== 'jkkp6' && form !== 'jkkp7')
|
||||||
|
return NextResponse.json({ error: 'form must be jkkp6 or jkkp7' }, { status: 422 })
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('users').select('role').eq('id', user.id).single()
|
||||||
|
if (!profile || !['hse', 'admin'].includes(profile.role))
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const { data: incident } = await supabase
|
||||||
|
.from('incidents')
|
||||||
|
.select(`
|
||||||
|
reference_no, incident_type, description, reported_at, severity, lost_days,
|
||||||
|
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
|
||||||
|
sites (name),
|
||||||
|
reporter:users!reported_by (name)
|
||||||
|
`)
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const dosh = computeDoshObligation({
|
||||||
|
is_fatality: (incident as { is_fatality: boolean }).is_fatality,
|
||||||
|
is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
|
||||||
|
is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
|
||||||
|
is_occupational_disease: (incident as { is_occupational_disease: boolean }).is_occupational_disease,
|
||||||
|
lost_days: (incident as { lost_days: number | null }).lost_days,
|
||||||
|
})
|
||||||
|
|
||||||
|
const required = form === 'jkkp6' ? dosh.requires_jkkp6 : dosh.requires_jkkp7
|
||||||
|
if (!required) return NextResponse.json({ error: 'This form is not required for this incident' }, { status: 400 })
|
||||||
|
|
||||||
|
const jkkpIncident: JkkpIncident = {
|
||||||
|
reference_no: (incident as { reference_no: string | null }).reference_no,
|
||||||
|
incident_type: (incident as { incident_type: string }).incident_type,
|
||||||
|
description: (incident as { description: string }).description,
|
||||||
|
reported_at: (incident as { reported_at: string }).reported_at,
|
||||||
|
severity: (incident as { severity: number | null }).severity,
|
||||||
|
is_fatality: (incident as { is_fatality: boolean }).is_fatality,
|
||||||
|
is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
|
||||||
|
is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
|
||||||
|
lost_days: (incident as { lost_days: number | null }).lost_days,
|
||||||
|
site_name: (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown',
|
||||||
|
reporter_name: (incident.reporter as unknown as { name: string } | null)?.name ?? 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = form === 'jkkp6'
|
||||||
|
? await buildJkkp6Pdf(jkkpIncident)
|
||||||
|
: await buildJkkp7Pdf(jkkpIncident)
|
||||||
|
|
||||||
|
const ref = jkkpIncident.reference_no ?? id
|
||||||
|
return new NextResponse(Buffer.from(pdfBytes), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${form}-${ref}.pdf"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
|
||||||
|
|
||||||
|
export type JkkpIncident = {
|
||||||
|
reference_no: string | null
|
||||||
|
incident_type: string
|
||||||
|
description: string
|
||||||
|
reported_at: string
|
||||||
|
severity: number | null
|
||||||
|
is_fatality: boolean
|
||||||
|
is_serious_bodily_injury: boolean
|
||||||
|
is_dangerous_occurrence: boolean
|
||||||
|
lost_days: number | null
|
||||||
|
site_name: string
|
||||||
|
reporter_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmbeddedFont = Awaited<ReturnType<PDFDocument['embedFont']>>
|
||||||
|
type Page = ReturnType<PDFDocument['addPage']>
|
||||||
|
|
||||||
|
async function createBaseDoc(title: string): Promise<{
|
||||||
|
doc: PDFDocument
|
||||||
|
page: Page
|
||||||
|
font: EmbeddedFont
|
||||||
|
boldFont: EmbeddedFont
|
||||||
|
y: { value: number }
|
||||||
|
}> {
|
||||||
|
const doc = await PDFDocument.create()
|
||||||
|
const page = doc.addPage([595, 842]) // A4
|
||||||
|
const font = await doc.embedFont(StandardFonts.Helvetica)
|
||||||
|
const boldFont = await doc.embedFont(StandardFonts.HelveticaBold)
|
||||||
|
const y = { value: 800 }
|
||||||
|
|
||||||
|
page.drawText(title, { x: 50, y: y.value, size: 14, font: boldFont, color: rgb(0, 0, 0) })
|
||||||
|
y.value -= 8
|
||||||
|
page.drawLine({ start: { x: 50, y: y.value }, end: { x: 545, y: y.value }, thickness: 1, color: rgb(0, 0, 0) })
|
||||||
|
y.value -= 20
|
||||||
|
|
||||||
|
return { doc, page, font, boldFont, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawField(
|
||||||
|
page: Page,
|
||||||
|
label: string,
|
||||||
|
value: string,
|
||||||
|
font: EmbeddedFont,
|
||||||
|
boldFont: EmbeddedFont,
|
||||||
|
y: { value: number }
|
||||||
|
) {
|
||||||
|
page.drawText(label + ':', { x: 50, y: y.value, size: 9, font: boldFont, color: rgb(0.3, 0.3, 0.3) })
|
||||||
|
const lines = value.length > 80 ? [value.slice(0, 80), value.slice(80, 160)] : [value]
|
||||||
|
for (const line of lines) {
|
||||||
|
y.value -= 14
|
||||||
|
page.drawText(line || '—', { x: 50, y: y.value, size: 10, font, color: rgb(0, 0, 0) })
|
||||||
|
}
|
||||||
|
y.value -= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildJkkp6Pdf(incident: JkkpIncident): Promise<Uint8Array> {
|
||||||
|
const { doc, page, font, boldFont, y } = await createBaseDoc('BORANG JKKP 6 — Notis Kemalangan / Kejadian Berbahaya')
|
||||||
|
|
||||||
|
const reportedDate = new Date(incident.reported_at).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||||
|
|
||||||
|
drawField(page, 'Rujukan / Reference', incident.reference_no ?? '—', font, boldFont, y)
|
||||||
|
drawField(page, 'Tapak / Site', incident.site_name, font, boldFont, y)
|
||||||
|
drawField(page, 'Tarikh Laporan / Date Reported', reportedDate, font, boldFont, y)
|
||||||
|
drawField(page, 'Jenis Insiden / Incident Type', incident.incident_type.replace(/_/g, ' '), font, boldFont, y)
|
||||||
|
drawField(page, 'Dilaporkan Oleh / Reported By', incident.reporter_name, font, boldFont, y)
|
||||||
|
drawField(page, 'Penerangan / Description', incident.description, font, boldFont, y)
|
||||||
|
drawField(page, 'Kematian / Fatality', incident.is_fatality ? 'Yes' : 'No', font, boldFont, y)
|
||||||
|
drawField(page, 'Kecederaan Serius / Serious Bodily Injury', incident.is_serious_bodily_injury ? 'Yes' : 'No', font, boldFont, y)
|
||||||
|
drawField(page, 'Kejadian Berbahaya / Dangerous Occurrence', incident.is_dangerous_occurrence ? 'Yes' : 'No', font, boldFont, y)
|
||||||
|
drawField(page, 'Hari Hilang Kerja / Lost Days', incident.lost_days !== null ? String(incident.lost_days) : '—', font, boldFont, y)
|
||||||
|
|
||||||
|
y.value -= 20
|
||||||
|
page.drawText('Nota: Borang ini adalah draf yang dijana secara automatik. Sila semak sebelum dikemukakan ke DOSH.',
|
||||||
|
{ x: 50, y: y.value, size: 8, font, color: rgb(0.5, 0.5, 0.5) })
|
||||||
|
|
||||||
|
return doc.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildJkkp7Pdf(incident: JkkpIncident): Promise<Uint8Array> {
|
||||||
|
const { doc, page, font, boldFont, y } = await createBaseDoc('BORANG JKKP 7 — Laporan Siasatan Kemalangan')
|
||||||
|
|
||||||
|
const reportedDate = new Date(incident.reported_at).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||||
|
|
||||||
|
drawField(page, 'Rujukan / Reference', incident.reference_no ?? '—', font, boldFont, y)
|
||||||
|
drawField(page, 'Tapak / Site', incident.site_name, font, boldFont, y)
|
||||||
|
drawField(page, 'Tarikh Kemalangan / Incident Date', reportedDate, font, boldFont, y)
|
||||||
|
drawField(page, 'Jenis Insiden / Incident Type', incident.incident_type.replace(/_/g, ' '), font, boldFont, y)
|
||||||
|
drawField(page, 'Penerangan Kemalangan / Incident Description', incident.description, font, boldFont, y)
|
||||||
|
drawField(page, 'Keterukan / Severity', incident.severity !== null ? String(incident.severity) + ' / 5' : '—', font, boldFont, y)
|
||||||
|
drawField(page, 'Hari Hilang Kerja / Lost Days', incident.lost_days !== null ? String(incident.lost_days) : '—', font, boldFont, y)
|
||||||
|
|
||||||
|
y.value -= 20
|
||||||
|
page.drawText('Rumusan Punca Asas / Root Cause Summary:', { x: 50, y: y.value, size: 9, font: boldFont, color: rgb(0.3, 0.3, 0.3) })
|
||||||
|
y.value -= 14
|
||||||
|
page.drawText('[To be completed by HSE investigator]', { x: 50, y: y.value, size: 10, font, color: rgb(0.6, 0.6, 0.6) })
|
||||||
|
|
||||||
|
y.value -= 30
|
||||||
|
page.drawText('Nota: Borang ini adalah draf yang dijana secara automatik. Sila semak sebelum dikemukakan ke DOSH.',
|
||||||
|
{ x: 50, y: y.value, size: 8, font, color: rgb(0.5, 0.5, 0.5) })
|
||||||
|
|
||||||
|
return doc.save()
|
||||||
|
}
|
||||||
Generated
+43
@@ -11,6 +11,7 @@
|
|||||||
"@supabase/ssr": "^0.12.0",
|
"@supabase/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.110.2",
|
"@supabase/supabase-js": "^2.110.2",
|
||||||
"next": "^15.5.20",
|
"next": "^15.5.20",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"resend": "^6.17.2"
|
"resend": "^6.17.2"
|
||||||
@@ -2008,6 +2009,24 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||||
@@ -7305,6 +7324,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -7365,6 +7390,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@supabase/ssr": "^0.12.0",
|
"@supabase/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.110.2",
|
"@supabase/supabase-js": "^2.110.2",
|
||||||
"next": "^15.5.20",
|
"next": "^15.5.20",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"resend": "^6.17.2"
|
"resend": "^6.17.2"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildJkkp6Pdf, buildJkkp7Pdf } from '@/lib/pdf/jkkp'
|
||||||
|
|
||||||
|
const mockIncident = {
|
||||||
|
reference_no: 'TEST-202607-0001',
|
||||||
|
incident_type: 'injury',
|
||||||
|
description: 'Worker fell from platform',
|
||||||
|
reported_at: new Date('2026-07-01T08:00:00Z').toISOString(),
|
||||||
|
severity: 4,
|
||||||
|
is_fatality: false,
|
||||||
|
is_serious_bodily_injury: true,
|
||||||
|
is_dangerous_occurrence: false,
|
||||||
|
lost_days: 5,
|
||||||
|
site_name: 'Setia Corporation Warehouse 1',
|
||||||
|
reporter_name: 'John Doe',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildJkkp6Pdf', () => {
|
||||||
|
it('returns a non-empty Uint8Array', async () => {
|
||||||
|
const pdf = await buildJkkp6Pdf(mockIncident)
|
||||||
|
expect(pdf).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(pdf.length).toBeGreaterThan(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PDF bytes start with %PDF', async () => {
|
||||||
|
const pdf = await buildJkkp6Pdf(mockIncident)
|
||||||
|
const header = new TextDecoder().decode(pdf.slice(0, 4))
|
||||||
|
expect(header).toBe('%PDF')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildJkkp7Pdf', () => {
|
||||||
|
it('returns a non-empty Uint8Array', async () => {
|
||||||
|
const pdf = await buildJkkp7Pdf(mockIncident)
|
||||||
|
expect(pdf).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(pdf.length).toBeGreaterThan(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PDF bytes start with %PDF', async () => {
|
||||||
|
const pdf = await buildJkkp7Pdf(mockIncident)
|
||||||
|
const header = new TextDecoder().decode(pdf.slice(0, 4))
|
||||||
|
expect(header).toBe('%PDF')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: [],
|
setupFiles: [],
|
||||||
|
exclude: ['node_modules/**', 'node_modules.nosync/**', '.next/**'],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user