Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
3048
apps/backend/pnpm-lock.yaml
generated
3048
apps/backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../access/adminOnly'
|
||||
import { auditGlobalChange } from '../collections/Audit/hooks/auditHooks'
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateFooter } from './hooks/revalidateFooter'
|
||||
|
||||
@@ -43,6 +44,6 @@ export const Footer: GlobalConfig = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateFooter],
|
||||
afterChange: [revalidateFooter, auditGlobalChange('footer')],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../access/adminOnly'
|
||||
import { auditGlobalChange } from '../collections/Audit/hooks/auditHooks'
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateHeader } from './hooks/revalidateHeader'
|
||||
|
||||
@@ -29,6 +30,6 @@ export const Header: GlobalConfig = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateHeader],
|
||||
afterChange: [revalidateHeader, auditGlobalChange('header')],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Checkbox: React.FC<
|
||||
CheckboxField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
const props = register(name, { required: required })
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxUi
|
||||
defaultChecked={defaultValue}
|
||||
id={name}
|
||||
{...props}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue(props.name, checked)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
'use client'
|
||||
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import RichText from '@/components/RichText'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { fields } from './fields'
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
|
||||
export type FormBlockType = {
|
||||
blockName?: string
|
||||
blockType?: 'formBlock'
|
||||
enableIntro: boolean
|
||||
form: FormType
|
||||
introContent?: DefaultTypedEditorState
|
||||
}
|
||||
|
||||
export const FormBlock: React.FC<
|
||||
{
|
||||
id?: string
|
||||
} & FormBlockType
|
||||
> = (props) => {
|
||||
const {
|
||||
enableIntro,
|
||||
form: formFromProps,
|
||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
||||
introContent,
|
||||
} = props
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: formFromProps.fields,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
register,
|
||||
} = formMethods
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
|
||||
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormFieldBlock[]) => {
|
||||
let loadingTimerID: ReturnType<typeof setTimeout>
|
||||
const submitForm = async () => {
|
||||
setError(undefined)
|
||||
|
||||
const dataToSend = Object.entries(data).map(([name, value]) => ({
|
||||
field: name,
|
||||
value,
|
||||
}))
|
||||
|
||||
// delay loading indicator by 1s
|
||||
loadingTimerID = setTimeout(() => {
|
||||
setIsLoading(true)
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const req = await fetch(`${getClientSideURL()}/api/form-submissions`, {
|
||||
body: JSON.stringify({
|
||||
form: formID,
|
||||
submissionData: dataToSend,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const res = (await req.json()) as any
|
||||
|
||||
clearTimeout(loadingTimerID)
|
||||
|
||||
if (req.status >= 400) {
|
||||
setIsLoading(false)
|
||||
|
||||
setError({
|
||||
message: res.errors?.[0]?.message || 'Internal Server Error',
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (confirmationType === 'redirect' && redirect) {
|
||||
const { url } = redirect
|
||||
|
||||
const redirectUrl = url
|
||||
|
||||
if (redirectUrl) router.push(redirectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
setIsLoading(false)
|
||||
setError({
|
||||
message: 'Something went wrong.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void submitForm()
|
||||
},
|
||||
[router, formID, redirect, confirmationType],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container lg:max-w-[48rem]">
|
||||
{enableIntro && introContent && !hasSubmitted && (
|
||||
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
|
||||
)}
|
||||
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
|
||||
<FormProvider {...formMethods}>
|
||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
||||
<RichText data={confirmationMessage as DefaultTypedEditorState} />
|
||||
)}
|
||||
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
|
||||
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
|
||||
{!hasSubmitted && (
|
||||
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 last:mb-0">
|
||||
{formFromProps &&
|
||||
formFromProps.fields &&
|
||||
formFromProps.fields?.map((field, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
|
||||
if (Field) {
|
||||
return (
|
||||
<div className="mb-6 last:mb-0" key={index}>
|
||||
<Field
|
||||
form={formFromProps}
|
||||
{...field}
|
||||
{...formMethods}
|
||||
control={control}
|
||||
errors={errors}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button form={formID} type="submit" variant="default">
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { countryOptions } from './options'
|
||||
|
||||
export const Country: React.FC<
|
||||
CountryField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label className="" htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = countryOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countryOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,982 +0,0 @@
|
||||
export const countryOptions = [
|
||||
{
|
||||
label: 'Afghanistan',
|
||||
value: 'AF',
|
||||
},
|
||||
{
|
||||
label: 'Åland Islands',
|
||||
value: 'AX',
|
||||
},
|
||||
{
|
||||
label: 'Albania',
|
||||
value: 'AL',
|
||||
},
|
||||
{
|
||||
label: 'Algeria',
|
||||
value: 'DZ',
|
||||
},
|
||||
{
|
||||
label: 'American Samoa',
|
||||
value: 'AS',
|
||||
},
|
||||
{
|
||||
label: 'Andorra',
|
||||
value: 'AD',
|
||||
},
|
||||
{
|
||||
label: 'Angola',
|
||||
value: 'AO',
|
||||
},
|
||||
{
|
||||
label: 'Anguilla',
|
||||
value: 'AI',
|
||||
},
|
||||
{
|
||||
label: 'Antarctica',
|
||||
value: 'AQ',
|
||||
},
|
||||
{
|
||||
label: 'Antigua and Barbuda',
|
||||
value: 'AG',
|
||||
},
|
||||
{
|
||||
label: 'Argentina',
|
||||
value: 'AR',
|
||||
},
|
||||
{
|
||||
label: 'Armenia',
|
||||
value: 'AM',
|
||||
},
|
||||
{
|
||||
label: 'Aruba',
|
||||
value: 'AW',
|
||||
},
|
||||
{
|
||||
label: 'Australia',
|
||||
value: 'AU',
|
||||
},
|
||||
{
|
||||
label: 'Austria',
|
||||
value: 'AT',
|
||||
},
|
||||
{
|
||||
label: 'Azerbaijan',
|
||||
value: 'AZ',
|
||||
},
|
||||
{
|
||||
label: 'Bahamas',
|
||||
value: 'BS',
|
||||
},
|
||||
{
|
||||
label: 'Bahrain',
|
||||
value: 'BH',
|
||||
},
|
||||
{
|
||||
label: 'Bangladesh',
|
||||
value: 'BD',
|
||||
},
|
||||
{
|
||||
label: 'Barbados',
|
||||
value: 'BB',
|
||||
},
|
||||
{
|
||||
label: 'Belarus',
|
||||
value: 'BY',
|
||||
},
|
||||
{
|
||||
label: 'Belgium',
|
||||
value: 'BE',
|
||||
},
|
||||
{
|
||||
label: 'Belize',
|
||||
value: 'BZ',
|
||||
},
|
||||
{
|
||||
label: 'Benin',
|
||||
value: 'BJ',
|
||||
},
|
||||
{
|
||||
label: 'Bermuda',
|
||||
value: 'BM',
|
||||
},
|
||||
{
|
||||
label: 'Bhutan',
|
||||
value: 'BT',
|
||||
},
|
||||
{
|
||||
label: 'Bolivia',
|
||||
value: 'BO',
|
||||
},
|
||||
{
|
||||
label: 'Bosnia and Herzegovina',
|
||||
value: 'BA',
|
||||
},
|
||||
{
|
||||
label: 'Botswana',
|
||||
value: 'BW',
|
||||
},
|
||||
{
|
||||
label: 'Bouvet Island',
|
||||
value: 'BV',
|
||||
},
|
||||
{
|
||||
label: 'Brazil',
|
||||
value: 'BR',
|
||||
},
|
||||
{
|
||||
label: 'British Indian Ocean Territory',
|
||||
value: 'IO',
|
||||
},
|
||||
{
|
||||
label: 'Brunei Darussalam',
|
||||
value: 'BN',
|
||||
},
|
||||
{
|
||||
label: 'Bulgaria',
|
||||
value: 'BG',
|
||||
},
|
||||
{
|
||||
label: 'Burkina Faso',
|
||||
value: 'BF',
|
||||
},
|
||||
{
|
||||
label: 'Burundi',
|
||||
value: 'BI',
|
||||
},
|
||||
{
|
||||
label: 'Cambodia',
|
||||
value: 'KH',
|
||||
},
|
||||
{
|
||||
label: 'Cameroon',
|
||||
value: 'CM',
|
||||
},
|
||||
{
|
||||
label: 'Canada',
|
||||
value: 'CA',
|
||||
},
|
||||
{
|
||||
label: 'Cape Verde',
|
||||
value: 'CV',
|
||||
},
|
||||
{
|
||||
label: 'Cayman Islands',
|
||||
value: 'KY',
|
||||
},
|
||||
{
|
||||
label: 'Central African Republic',
|
||||
value: 'CF',
|
||||
},
|
||||
{
|
||||
label: 'Chad',
|
||||
value: 'TD',
|
||||
},
|
||||
{
|
||||
label: 'Chile',
|
||||
value: 'CL',
|
||||
},
|
||||
{
|
||||
label: 'China',
|
||||
value: 'CN',
|
||||
},
|
||||
{
|
||||
label: 'Christmas Island',
|
||||
value: 'CX',
|
||||
},
|
||||
{
|
||||
label: 'Cocos (Keeling) Islands',
|
||||
value: 'CC',
|
||||
},
|
||||
{
|
||||
label: 'Colombia',
|
||||
value: 'CO',
|
||||
},
|
||||
{
|
||||
label: 'Comoros',
|
||||
value: 'KM',
|
||||
},
|
||||
{
|
||||
label: 'Congo',
|
||||
value: 'CG',
|
||||
},
|
||||
{
|
||||
label: 'Congo, The Democratic Republic of the',
|
||||
value: 'CD',
|
||||
},
|
||||
{
|
||||
label: 'Cook Islands',
|
||||
value: 'CK',
|
||||
},
|
||||
{
|
||||
label: 'Costa Rica',
|
||||
value: 'CR',
|
||||
},
|
||||
{
|
||||
label: "Cote D'Ivoire",
|
||||
value: 'CI',
|
||||
},
|
||||
{
|
||||
label: 'Croatia',
|
||||
value: 'HR',
|
||||
},
|
||||
{
|
||||
label: 'Cuba',
|
||||
value: 'CU',
|
||||
},
|
||||
{
|
||||
label: 'Cyprus',
|
||||
value: 'CY',
|
||||
},
|
||||
{
|
||||
label: 'Czech Republic',
|
||||
value: 'CZ',
|
||||
},
|
||||
{
|
||||
label: 'Denmark',
|
||||
value: 'DK',
|
||||
},
|
||||
{
|
||||
label: 'Djibouti',
|
||||
value: 'DJ',
|
||||
},
|
||||
{
|
||||
label: 'Dominica',
|
||||
value: 'DM',
|
||||
},
|
||||
{
|
||||
label: 'Dominican Republic',
|
||||
value: 'DO',
|
||||
},
|
||||
{
|
||||
label: 'Ecuador',
|
||||
value: 'EC',
|
||||
},
|
||||
{
|
||||
label: 'Egypt',
|
||||
value: 'EG',
|
||||
},
|
||||
{
|
||||
label: 'El Salvador',
|
||||
value: 'SV',
|
||||
},
|
||||
{
|
||||
label: 'Equatorial Guinea',
|
||||
value: 'GQ',
|
||||
},
|
||||
{
|
||||
label: 'Eritrea',
|
||||
value: 'ER',
|
||||
},
|
||||
{
|
||||
label: 'Estonia',
|
||||
value: 'EE',
|
||||
},
|
||||
{
|
||||
label: 'Ethiopia',
|
||||
value: 'ET',
|
||||
},
|
||||
{
|
||||
label: 'Falkland Islands (Malvinas)',
|
||||
value: 'FK',
|
||||
},
|
||||
{
|
||||
label: 'Faroe Islands',
|
||||
value: 'FO',
|
||||
},
|
||||
{
|
||||
label: 'Fiji',
|
||||
value: 'FJ',
|
||||
},
|
||||
{
|
||||
label: 'Finland',
|
||||
value: 'FI',
|
||||
},
|
||||
{
|
||||
label: 'France',
|
||||
value: 'FR',
|
||||
},
|
||||
{
|
||||
label: 'French Guiana',
|
||||
value: 'GF',
|
||||
},
|
||||
{
|
||||
label: 'French Polynesia',
|
||||
value: 'PF',
|
||||
},
|
||||
{
|
||||
label: 'French Southern Territories',
|
||||
value: 'TF',
|
||||
},
|
||||
{
|
||||
label: 'Gabon',
|
||||
value: 'GA',
|
||||
},
|
||||
{
|
||||
label: 'Gambia',
|
||||
value: 'GM',
|
||||
},
|
||||
{
|
||||
label: 'Georgia',
|
||||
value: 'GE',
|
||||
},
|
||||
{
|
||||
label: 'Germany',
|
||||
value: 'DE',
|
||||
},
|
||||
{
|
||||
label: 'Ghana',
|
||||
value: 'GH',
|
||||
},
|
||||
{
|
||||
label: 'Gibraltar',
|
||||
value: 'GI',
|
||||
},
|
||||
{
|
||||
label: 'Greece',
|
||||
value: 'GR',
|
||||
},
|
||||
{
|
||||
label: 'Greenland',
|
||||
value: 'GL',
|
||||
},
|
||||
{
|
||||
label: 'Grenada',
|
||||
value: 'GD',
|
||||
},
|
||||
{
|
||||
label: 'Guadeloupe',
|
||||
value: 'GP',
|
||||
},
|
||||
{
|
||||
label: 'Guam',
|
||||
value: 'GU',
|
||||
},
|
||||
{
|
||||
label: 'Guatemala',
|
||||
value: 'GT',
|
||||
},
|
||||
{
|
||||
label: 'Guernsey',
|
||||
value: 'GG',
|
||||
},
|
||||
{
|
||||
label: 'Guinea',
|
||||
value: 'GN',
|
||||
},
|
||||
{
|
||||
label: 'Guinea-Bissau',
|
||||
value: 'GW',
|
||||
},
|
||||
{
|
||||
label: 'Guyana',
|
||||
value: 'GY',
|
||||
},
|
||||
{
|
||||
label: 'Haiti',
|
||||
value: 'HT',
|
||||
},
|
||||
{
|
||||
label: 'Heard Island and Mcdonald Islands',
|
||||
value: 'HM',
|
||||
},
|
||||
{
|
||||
label: 'Holy See (Vatican City State)',
|
||||
value: 'VA',
|
||||
},
|
||||
{
|
||||
label: 'Honduras',
|
||||
value: 'HN',
|
||||
},
|
||||
{
|
||||
label: 'Hong Kong',
|
||||
value: 'HK',
|
||||
},
|
||||
{
|
||||
label: 'Hungary',
|
||||
value: 'HU',
|
||||
},
|
||||
{
|
||||
label: 'Iceland',
|
||||
value: 'IS',
|
||||
},
|
||||
{
|
||||
label: 'India',
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'Indonesia',
|
||||
value: 'ID',
|
||||
},
|
||||
{
|
||||
label: 'Iran, Islamic Republic Of',
|
||||
value: 'IR',
|
||||
},
|
||||
{
|
||||
label: 'Iraq',
|
||||
value: 'IQ',
|
||||
},
|
||||
{
|
||||
label: 'Ireland',
|
||||
value: 'IE',
|
||||
},
|
||||
{
|
||||
label: 'Isle of Man',
|
||||
value: 'IM',
|
||||
},
|
||||
{
|
||||
label: 'Israel',
|
||||
value: 'IL',
|
||||
},
|
||||
{
|
||||
label: 'Italy',
|
||||
value: 'IT',
|
||||
},
|
||||
{
|
||||
label: 'Jamaica',
|
||||
value: 'JM',
|
||||
},
|
||||
{
|
||||
label: 'Japan',
|
||||
value: 'JP',
|
||||
},
|
||||
{
|
||||
label: 'Jersey',
|
||||
value: 'JE',
|
||||
},
|
||||
{
|
||||
label: 'Jordan',
|
||||
value: 'JO',
|
||||
},
|
||||
{
|
||||
label: 'Kazakhstan',
|
||||
value: 'KZ',
|
||||
},
|
||||
{
|
||||
label: 'Kenya',
|
||||
value: 'KE',
|
||||
},
|
||||
{
|
||||
label: 'Kiribati',
|
||||
value: 'KI',
|
||||
},
|
||||
{
|
||||
label: "Democratic People's Republic of Korea",
|
||||
value: 'KP',
|
||||
},
|
||||
{
|
||||
label: 'Korea, Republic of',
|
||||
value: 'KR',
|
||||
},
|
||||
{
|
||||
label: 'Kosovo',
|
||||
value: 'XK',
|
||||
},
|
||||
{
|
||||
label: 'Kuwait',
|
||||
value: 'KW',
|
||||
},
|
||||
{
|
||||
label: 'Kyrgyzstan',
|
||||
value: 'KG',
|
||||
},
|
||||
{
|
||||
label: "Lao People's Democratic Republic",
|
||||
value: 'LA',
|
||||
},
|
||||
{
|
||||
label: 'Latvia',
|
||||
value: 'LV',
|
||||
},
|
||||
{
|
||||
label: 'Lebanon',
|
||||
value: 'LB',
|
||||
},
|
||||
{
|
||||
label: 'Lesotho',
|
||||
value: 'LS',
|
||||
},
|
||||
{
|
||||
label: 'Liberia',
|
||||
value: 'LR',
|
||||
},
|
||||
{
|
||||
label: 'Libyan Arab Jamahiriya',
|
||||
value: 'LY',
|
||||
},
|
||||
{
|
||||
label: 'Liechtenstein',
|
||||
value: 'LI',
|
||||
},
|
||||
{
|
||||
label: 'Lithuania',
|
||||
value: 'LT',
|
||||
},
|
||||
{
|
||||
label: 'Luxembourg',
|
||||
value: 'LU',
|
||||
},
|
||||
{
|
||||
label: 'Macao',
|
||||
value: 'MO',
|
||||
},
|
||||
{
|
||||
label: 'Macedonia, The Former Yugoslav Republic of',
|
||||
value: 'MK',
|
||||
},
|
||||
{
|
||||
label: 'Madagascar',
|
||||
value: 'MG',
|
||||
},
|
||||
{
|
||||
label: 'Malawi',
|
||||
value: 'MW',
|
||||
},
|
||||
{
|
||||
label: 'Malaysia',
|
||||
value: 'MY',
|
||||
},
|
||||
{
|
||||
label: 'Maldives',
|
||||
value: 'MV',
|
||||
},
|
||||
{
|
||||
label: 'Mali',
|
||||
value: 'ML',
|
||||
},
|
||||
{
|
||||
label: 'Malta',
|
||||
value: 'MT',
|
||||
},
|
||||
{
|
||||
label: 'Marshall Islands',
|
||||
value: 'MH',
|
||||
},
|
||||
{
|
||||
label: 'Martinique',
|
||||
value: 'MQ',
|
||||
},
|
||||
{
|
||||
label: 'Mauritania',
|
||||
value: 'MR',
|
||||
},
|
||||
{
|
||||
label: 'Mauritius',
|
||||
value: 'MU',
|
||||
},
|
||||
{
|
||||
label: 'Mayotte',
|
||||
value: 'YT',
|
||||
},
|
||||
{
|
||||
label: 'Mexico',
|
||||
value: 'MX',
|
||||
},
|
||||
{
|
||||
label: 'Micronesia, Federated States of',
|
||||
value: 'FM',
|
||||
},
|
||||
{
|
||||
label: 'Moldova, Republic of',
|
||||
value: 'MD',
|
||||
},
|
||||
{
|
||||
label: 'Monaco',
|
||||
value: 'MC',
|
||||
},
|
||||
{
|
||||
label: 'Mongolia',
|
||||
value: 'MN',
|
||||
},
|
||||
{
|
||||
label: 'Montenegro',
|
||||
value: 'ME',
|
||||
},
|
||||
{
|
||||
label: 'Montserrat',
|
||||
value: 'MS',
|
||||
},
|
||||
{
|
||||
label: 'Morocco',
|
||||
value: 'MA',
|
||||
},
|
||||
{
|
||||
label: 'Mozambique',
|
||||
value: 'MZ',
|
||||
},
|
||||
{
|
||||
label: 'Myanmar',
|
||||
value: 'MM',
|
||||
},
|
||||
{
|
||||
label: 'Namibia',
|
||||
value: 'NA',
|
||||
},
|
||||
{
|
||||
label: 'Nauru',
|
||||
value: 'NR',
|
||||
},
|
||||
{
|
||||
label: 'Nepal',
|
||||
value: 'NP',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands',
|
||||
value: 'NL',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands Antilles',
|
||||
value: 'AN',
|
||||
},
|
||||
{
|
||||
label: 'New Caledonia',
|
||||
value: 'NC',
|
||||
},
|
||||
{
|
||||
label: 'New Zealand',
|
||||
value: 'NZ',
|
||||
},
|
||||
{
|
||||
label: 'Nicaragua',
|
||||
value: 'NI',
|
||||
},
|
||||
{
|
||||
label: 'Niger',
|
||||
value: 'NE',
|
||||
},
|
||||
{
|
||||
label: 'Nigeria',
|
||||
value: 'NG',
|
||||
},
|
||||
{
|
||||
label: 'Niue',
|
||||
value: 'NU',
|
||||
},
|
||||
{
|
||||
label: 'Norfolk Island',
|
||||
value: 'NF',
|
||||
},
|
||||
{
|
||||
label: 'Northern Mariana Islands',
|
||||
value: 'MP',
|
||||
},
|
||||
{
|
||||
label: 'Norway',
|
||||
value: 'NO',
|
||||
},
|
||||
{
|
||||
label: 'Oman',
|
||||
value: 'OM',
|
||||
},
|
||||
{
|
||||
label: 'Pakistan',
|
||||
value: 'PK',
|
||||
},
|
||||
{
|
||||
label: 'Palau',
|
||||
value: 'PW',
|
||||
},
|
||||
{
|
||||
label: 'Palestinian Territory, Occupied',
|
||||
value: 'PS',
|
||||
},
|
||||
{
|
||||
label: 'Panama',
|
||||
value: 'PA',
|
||||
},
|
||||
{
|
||||
label: 'Papua New Guinea',
|
||||
value: 'PG',
|
||||
},
|
||||
{
|
||||
label: 'Paraguay',
|
||||
value: 'PY',
|
||||
},
|
||||
{
|
||||
label: 'Peru',
|
||||
value: 'PE',
|
||||
},
|
||||
{
|
||||
label: 'Philippines',
|
||||
value: 'PH',
|
||||
},
|
||||
{
|
||||
label: 'Pitcairn',
|
||||
value: 'PN',
|
||||
},
|
||||
{
|
||||
label: 'Poland',
|
||||
value: 'PL',
|
||||
},
|
||||
{
|
||||
label: 'Portugal',
|
||||
value: 'PT',
|
||||
},
|
||||
{
|
||||
label: 'Puerto Rico',
|
||||
value: 'PR',
|
||||
},
|
||||
{
|
||||
label: 'Qatar',
|
||||
value: 'QA',
|
||||
},
|
||||
{
|
||||
label: 'Reunion',
|
||||
value: 'RE',
|
||||
},
|
||||
{
|
||||
label: 'Romania',
|
||||
value: 'RO',
|
||||
},
|
||||
{
|
||||
label: 'Russian Federation',
|
||||
value: 'RU',
|
||||
},
|
||||
{
|
||||
label: 'Rwanda',
|
||||
value: 'RW',
|
||||
},
|
||||
{
|
||||
label: 'Saint Helena',
|
||||
value: 'SH',
|
||||
},
|
||||
{
|
||||
label: 'Saint Kitts and Nevis',
|
||||
value: 'KN',
|
||||
},
|
||||
{
|
||||
label: 'Saint Lucia',
|
||||
value: 'LC',
|
||||
},
|
||||
{
|
||||
label: 'Saint Pierre and Miquelon',
|
||||
value: 'PM',
|
||||
},
|
||||
{
|
||||
label: 'Saint Vincent and the Grenadines',
|
||||
value: 'VC',
|
||||
},
|
||||
{
|
||||
label: 'Samoa',
|
||||
value: 'WS',
|
||||
},
|
||||
{
|
||||
label: 'San Marino',
|
||||
value: 'SM',
|
||||
},
|
||||
{
|
||||
label: 'Sao Tome and Principe',
|
||||
value: 'ST',
|
||||
},
|
||||
{
|
||||
label: 'Saudi Arabia',
|
||||
value: 'SA',
|
||||
},
|
||||
{
|
||||
label: 'Senegal',
|
||||
value: 'SN',
|
||||
},
|
||||
{
|
||||
label: 'Serbia',
|
||||
value: 'RS',
|
||||
},
|
||||
{
|
||||
label: 'Seychelles',
|
||||
value: 'SC',
|
||||
},
|
||||
{
|
||||
label: 'Sierra Leone',
|
||||
value: 'SL',
|
||||
},
|
||||
{
|
||||
label: 'Singapore',
|
||||
value: 'SG',
|
||||
},
|
||||
{
|
||||
label: 'Slovakia',
|
||||
value: 'SK',
|
||||
},
|
||||
{
|
||||
label: 'Slovenia',
|
||||
value: 'SI',
|
||||
},
|
||||
{
|
||||
label: 'Solomon Islands',
|
||||
value: 'SB',
|
||||
},
|
||||
{
|
||||
label: 'Somalia',
|
||||
value: 'SO',
|
||||
},
|
||||
{
|
||||
label: 'South Africa',
|
||||
value: 'ZA',
|
||||
},
|
||||
{
|
||||
label: 'South Georgia and the South Sandwich Islands',
|
||||
value: 'GS',
|
||||
},
|
||||
{
|
||||
label: 'Spain',
|
||||
value: 'ES',
|
||||
},
|
||||
{
|
||||
label: 'Sri Lanka',
|
||||
value: 'LK',
|
||||
},
|
||||
{
|
||||
label: 'Sudan',
|
||||
value: 'SD',
|
||||
},
|
||||
{
|
||||
label: 'Suriname',
|
||||
value: 'SR',
|
||||
},
|
||||
{
|
||||
label: 'Svalbard and Jan Mayen',
|
||||
value: 'SJ',
|
||||
},
|
||||
{
|
||||
label: 'Swaziland',
|
||||
value: 'SZ',
|
||||
},
|
||||
{
|
||||
label: 'Sweden',
|
||||
value: 'SE',
|
||||
},
|
||||
{
|
||||
label: 'Switzerland',
|
||||
value: 'CH',
|
||||
},
|
||||
{
|
||||
label: 'Syrian Arab Republic',
|
||||
value: 'SY',
|
||||
},
|
||||
{
|
||||
label: 'Taiwan',
|
||||
value: 'TW',
|
||||
},
|
||||
{
|
||||
label: 'Tajikistan',
|
||||
value: 'TJ',
|
||||
},
|
||||
{
|
||||
label: 'Tanzania, United Republic of',
|
||||
value: 'TZ',
|
||||
},
|
||||
{
|
||||
label: 'Thailand',
|
||||
value: 'TH',
|
||||
},
|
||||
{
|
||||
label: 'Timor-Leste',
|
||||
value: 'TL',
|
||||
},
|
||||
{
|
||||
label: 'Togo',
|
||||
value: 'TG',
|
||||
},
|
||||
{
|
||||
label: 'Tokelau',
|
||||
value: 'TK',
|
||||
},
|
||||
{
|
||||
label: 'Tonga',
|
||||
value: 'TO',
|
||||
},
|
||||
{
|
||||
label: 'Trinidad and Tobago',
|
||||
value: 'TT',
|
||||
},
|
||||
{
|
||||
label: 'Tunisia',
|
||||
value: 'TN',
|
||||
},
|
||||
{
|
||||
label: 'Turkey',
|
||||
value: 'TR',
|
||||
},
|
||||
{
|
||||
label: 'Turkmenistan',
|
||||
value: 'TM',
|
||||
},
|
||||
{
|
||||
label: 'Turks and Caicos Islands',
|
||||
value: 'TC',
|
||||
},
|
||||
{
|
||||
label: 'Tuvalu',
|
||||
value: 'TV',
|
||||
},
|
||||
{
|
||||
label: 'Uganda',
|
||||
value: 'UG',
|
||||
},
|
||||
{
|
||||
label: 'Ukraine',
|
||||
value: 'UA',
|
||||
},
|
||||
{
|
||||
label: 'United Arab Emirates',
|
||||
value: 'AE',
|
||||
},
|
||||
{
|
||||
label: 'United Kingdom',
|
||||
value: 'GB',
|
||||
},
|
||||
{
|
||||
label: 'United States',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
label: 'United States Minor Outlying Islands',
|
||||
value: 'UM',
|
||||
},
|
||||
{
|
||||
label: 'Uruguay',
|
||||
value: 'UY',
|
||||
},
|
||||
{
|
||||
label: 'Uzbekistan',
|
||||
value: 'UZ',
|
||||
},
|
||||
{
|
||||
label: 'Vanuatu',
|
||||
value: 'VU',
|
||||
},
|
||||
{
|
||||
label: 'Venezuela',
|
||||
value: 'VE',
|
||||
},
|
||||
{
|
||||
label: 'Viet Nam',
|
||||
value: 'VN',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, British',
|
||||
value: 'VG',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, U.S.',
|
||||
value: 'VI',
|
||||
},
|
||||
{
|
||||
label: 'Wallis and Futuna',
|
||||
value: 'WF',
|
||||
},
|
||||
{
|
||||
label: 'Western Sahara',
|
||||
value: 'EH',
|
||||
},
|
||||
{
|
||||
label: 'Yemen',
|
||||
value: 'YE',
|
||||
},
|
||||
{
|
||||
label: 'Zambia',
|
||||
value: 'ZM',
|
||||
},
|
||||
{
|
||||
label: 'Zimbabwe',
|
||||
value: 'ZW',
|
||||
},
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Email: React.FC<
|
||||
EmailField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="text"
|
||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
export const Error = ({ name }: { name: string }) => {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
return (
|
||||
<div className="mt-2 text-red-500 text-sm">
|
||||
{(errors[name]?.message as string) || 'This field is required'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import RichText from '@/components/RichText'
|
||||
import React from 'react'
|
||||
|
||||
import { Width } from '../Width'
|
||||
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
|
||||
return (
|
||||
<Width className="my-12" width="100">
|
||||
{message && <RichText data={message} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
export const Number: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="number"
|
||||
{...register(name, { required })}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select as SelectComponent,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Select: React.FC<
|
||||
SelectField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = options.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectComponent>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { StateField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { stateOptions } from './options'
|
||||
|
||||
export const State: React.FC<
|
||||
StateField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = stateOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stateOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export const stateOptions = [
|
||||
{ label: 'Alabama', value: 'AL' },
|
||||
{ label: 'Alaska', value: 'AK' },
|
||||
{ label: 'Arizona', value: 'AZ' },
|
||||
{ label: 'Arkansas', value: 'AR' },
|
||||
{ label: 'California', value: 'CA' },
|
||||
{ label: 'Colorado', value: 'CO' },
|
||||
{ label: 'Connecticut', value: 'CT' },
|
||||
{ label: 'Delaware', value: 'DE' },
|
||||
{ label: 'Florida', value: 'FL' },
|
||||
{ label: 'Georgia', value: 'GA' },
|
||||
{ label: 'Hawaii', value: 'HI' },
|
||||
{ label: 'Idaho', value: 'ID' },
|
||||
{ label: 'Illinois', value: 'IL' },
|
||||
{ label: 'Indiana', value: 'IN' },
|
||||
{ label: 'Iowa', value: 'IA' },
|
||||
{ label: 'Kansas', value: 'KS' },
|
||||
{ label: 'Kentucky', value: 'KY' },
|
||||
{ label: 'Louisiana', value: 'LA' },
|
||||
{ label: 'Maine', value: 'ME' },
|
||||
{ label: 'Maryland', value: 'MD' },
|
||||
{ label: 'Massachusetts', value: 'MA' },
|
||||
{ label: 'Michigan', value: 'MI' },
|
||||
{ label: 'Minnesota', value: 'MN' },
|
||||
{ label: 'Mississippi', value: 'MS' },
|
||||
{ label: 'Missouri', value: 'MO' },
|
||||
{ label: 'Montana', value: 'MT' },
|
||||
{ label: 'Nebraska', value: 'NE' },
|
||||
{ label: 'Nevada', value: 'NV' },
|
||||
{ label: 'New Hampshire', value: 'NH' },
|
||||
{ label: 'New Jersey', value: 'NJ' },
|
||||
{ label: 'New Mexico', value: 'NM' },
|
||||
{ label: 'New York', value: 'NY' },
|
||||
{ label: 'North Carolina', value: 'NC' },
|
||||
{ label: 'North Dakota', value: 'ND' },
|
||||
{ label: 'Ohio', value: 'OH' },
|
||||
{ label: 'Oklahoma', value: 'OK' },
|
||||
{ label: 'Oregon', value: 'OR' },
|
||||
{ label: 'Pennsylvania', value: 'PA' },
|
||||
{ label: 'Rhode Island', value: 'RI' },
|
||||
{ label: 'South Carolina', value: 'SC' },
|
||||
{ label: 'South Dakota', value: 'SD' },
|
||||
{ label: 'Tennessee', value: 'TN' },
|
||||
{ label: 'Texas', value: 'TX' },
|
||||
{ label: 'Utah', value: 'UT' },
|
||||
{ label: 'Vermont', value: 'VT' },
|
||||
{ label: 'Virginia', value: 'VA' },
|
||||
{ label: 'Washington', value: 'WA' },
|
||||
{ label: 'West Virginia', value: 'WV' },
|
||||
{ label: 'Wisconsin', value: 'WI' },
|
||||
{ label: 'Wyoming', value: 'WY' },
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Text: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Textarea: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
rows?: number
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<TextAreaComponent
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
rows={rows}
|
||||
{...register(name, { required: required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export const Width: React.FC<{
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
width?: number | string
|
||||
}> = ({ children, className, width }) => {
|
||||
return (
|
||||
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const FormBlock: Block = {
|
||||
slug: 'formBlock',
|
||||
interfaceName: 'FormBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'form',
|
||||
type: 'relationship',
|
||||
relationTo: 'forms',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'enableIntro',
|
||||
type: 'checkbox',
|
||||
label: 'Enable Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
admin: {
|
||||
condition: (_, { enableIntro }) => Boolean(enableIntro),
|
||||
},
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
],
|
||||
graphQL: {
|
||||
singularName: 'FormBlock',
|
||||
},
|
||||
labels: {
|
||||
plural: 'Form Blocks',
|
||||
singular: 'Form Block',
|
||||
},
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Checkbox } from './Checkbox'
|
||||
import { Country } from './Country'
|
||||
import { Email } from './Email'
|
||||
import { Message } from './Message'
|
||||
import { Number } from './Number'
|
||||
import { Select } from './Select'
|
||||
import { State } from './State'
|
||||
import { Text } from './Text'
|
||||
import { Textarea } from './Textarea'
|
||||
|
||||
export const fields = {
|
||||
checkbox: Checkbox,
|
||||
country: Country,
|
||||
email: Email,
|
||||
message: Message,
|
||||
number: Number,
|
||||
select: Select,
|
||||
state: State,
|
||||
text: Text,
|
||||
textarea: Textarea,
|
||||
}
|
||||
@@ -5,14 +5,12 @@ import type { Page } from '@/payload-types'
|
||||
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
|
||||
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
||||
import { ContentBlock } from '@/blocks/Content/Component'
|
||||
import { FormBlock } from '@/blocks/Form/Component'
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
||||
|
||||
const blockComponents = {
|
||||
archive: ArchiveBlock,
|
||||
content: ContentBlock,
|
||||
cta: CallToActionBlock,
|
||||
formBlock: FormBlock,
|
||||
mediaBlock: MediaBlock,
|
||||
}
|
||||
|
||||
|
||||
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AfterChangeHook } from 'payload'
|
||||
|
||||
import { logDocumentChange } from '@/utilities/auditLogger'
|
||||
|
||||
/**
|
||||
* 創建稽核日誌 Hook
|
||||
* 用於追蹤文件的創建、更新、刪除操作
|
||||
*/
|
||||
export const auditChange =
|
||||
(collection: string): AfterChangeHook =>
|
||||
async ({ doc, req }) => {
|
||||
// 跳過 audit 集合本身以避免無限循環
|
||||
if (collection === 'audit') return doc
|
||||
|
||||
await logDocumentChange(
|
||||
req,
|
||||
operation as 'create' | 'update' | 'delete',
|
||||
collection,
|
||||
doc.id as string,
|
||||
(doc.title || doc.name || String(doc.id)) as string,
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除稽核日誌 Hook
|
||||
* 用於追蹤文件刪除操作(需要在刪除前記錄標題)
|
||||
*/
|
||||
export const auditDelete =
|
||||
(collection: string): AfterChangeHook =>
|
||||
async ({ doc, req }) => {
|
||||
if (collection === 'audit') return doc
|
||||
|
||||
await logDocumentChange(
|
||||
req,
|
||||
'delete',
|
||||
collection,
|
||||
doc.id as string,
|
||||
(doc.title || doc.name || String(doc.id)) as string,
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* Global 稽核日誌 Hook
|
||||
* 用於追蹤 Global 配置的變更
|
||||
*/
|
||||
export const auditGlobalChange =
|
||||
(global: string): AfterChangeHook =>
|
||||
async ({ req, previousDoc, doc }) => {
|
||||
if (!req.user) return doc
|
||||
|
||||
const { auditLogger } = await import('@/utilities/auditLogger')
|
||||
|
||||
await auditLogger(req, {
|
||||
action: 'update',
|
||||
collection: `global_${global}`,
|
||||
userId: req.user.id,
|
||||
userName: req.user.name as string,
|
||||
userEmail: req.user.email as string,
|
||||
userRole: String(req.user.role ?? ''),
|
||||
changes: {
|
||||
previous: previousDoc,
|
||||
current: doc,
|
||||
},
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
108
apps/backend/src/collections/Audit/index.ts
Normal file
108
apps/backend/src/collections/Audit/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../../access/adminOnly'
|
||||
|
||||
export const Audit: CollectionConfig = {
|
||||
slug: 'audit',
|
||||
access: {
|
||||
create: () => false, // 僅透過程式自動創建
|
||||
delete: adminOnly,
|
||||
read: adminOnly,
|
||||
update: () => false, // 不允許修改日誌
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['timestamp', 'action', 'collection', 'userId'],
|
||||
useAsTitle: 'action',
|
||||
description: '系統稽核日誌 - 自動記錄所有重要操作',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '登入', value: 'login' },
|
||||
{ label: '登出', value: 'logout' },
|
||||
{ label: '創建', value: 'create' },
|
||||
{ label: '更新', value: 'update' },
|
||||
{ label: '刪除', value: 'delete' },
|
||||
{ label: '發布', value: 'publish' },
|
||||
{ label: '取消發布', value: 'unpublish' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: '受影響的集合名稱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '受影響的文件 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentTitle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '受影響的文件標題',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: '執行操作的使用者 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '執行操作的使用者名稱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userEmail',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '執行操作的使用者信箱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userRole',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '管理員', value: 'admin' },
|
||||
{ label: '編輯者', value: 'editor' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ipAddress',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'IP 位址',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userAgent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '瀏覽器 User Agent',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: '變更內容詳細資訊',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
||||
import { anyone } from '../access/anyone'
|
||||
import { authenticated } from '../access/authenticated'
|
||||
import { adminOrEditor } from '../access/adminOrEditor'
|
||||
import { auditChange } from './Audit/hooks/auditHooks'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
@@ -16,6 +17,10 @@ export const Categories: CollectionConfig = {
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [auditChange('categories')],
|
||||
afterDelete: [auditChange('categories')],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { Archive } from '../../blocks/ArchiveBlock/config'
|
||||
import { CallToAction } from '../../blocks/CallToAction/config'
|
||||
import { Content } from '../../blocks/Content/config'
|
||||
@@ -123,9 +124,9 @@ export const Pages: CollectionConfig<'pages'> = {
|
||||
...slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePage],
|
||||
afterChange: [revalidatePage, auditChange('pages')],
|
||||
beforeChange: [populatePublishedAt],
|
||||
afterDelete: [revalidateDelete],
|
||||
afterDelete: [revalidateDelete, auditChange('pages')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { anyone } from '../../access/anyone'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
export const Portfolio: CollectionConfig = {
|
||||
slug: 'portfolio',
|
||||
access: {
|
||||
create: adminOrEditor,
|
||||
create: authenticated,
|
||||
read: anyone,
|
||||
update: adminOrEditor,
|
||||
delete: adminOrEditor,
|
||||
update: authenticated,
|
||||
delete: authenticated,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
@@ -66,6 +67,10 @@ export const Portfolio: CollectionConfig = {
|
||||
},
|
||||
...slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [auditChange('portfolio')],
|
||||
afterDelete: [auditChange('portfolio')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { Banner } from '../../blocks/Banner/config'
|
||||
import { Code } from '../../blocks/Code/config'
|
||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||
@@ -263,9 +264,9 @@ export const Posts: CollectionConfig<'posts'> = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePost],
|
||||
afterChange: [revalidatePost, auditChange('posts')],
|
||||
afterRead: [populateAuthors],
|
||||
afterDelete: [revalidateDelete],
|
||||
afterDelete: [revalidateDelete, auditChange('posts')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { AfterLoginHook, AfterLogoutHook } from 'payload'
|
||||
|
||||
import { adminOnly } from '../../access/adminOnly'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { logLogin, logLogout } from '../../utilities/auditLogger'
|
||||
|
||||
const afterLogin: AfterLoginHook = async ({ req, user }) => {
|
||||
if (user?.id) {
|
||||
await logLogin(req, user.id)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
const afterLogout: AfterLogoutHook = async ({ req }) => {
|
||||
if (req.user?.id) {
|
||||
await logLogout(req, req.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
@@ -8,7 +24,7 @@ export const Users: CollectionConfig = {
|
||||
admin: adminOnly,
|
||||
create: adminOnly,
|
||||
delete: adminOnly,
|
||||
read: adminOnly,
|
||||
read: authenticated,
|
||||
update: adminOnly,
|
||||
},
|
||||
admin: {
|
||||
@@ -16,6 +32,10 @@ export const Users: CollectionConfig = {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
auth: true,
|
||||
hooks: {
|
||||
afterLogin: [afterLogin],
|
||||
afterLogout: [afterLogout],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { RequiredDataFromCollectionSlug } from 'payload'
|
||||
|
||||
export const contactForm: RequiredDataFromCollectionSlug<'forms'> = {
|
||||
confirmationMessage: {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: 'The contact form has been submitted successfully.',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
tag: 'h2',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
confirmationType: 'message',
|
||||
createdAt: '2023-01-12T21:47:41.374Z',
|
||||
emails: [
|
||||
{
|
||||
emailFrom: '"Payload" \u003Cdemo@payloadcms.com\u003E',
|
||||
emailTo: '{{email}}',
|
||||
message: {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: 'Your contact form submission was successfully received.',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
subject: "You've received a new message.",
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'full-name',
|
||||
blockName: 'full-name',
|
||||
blockType: 'text',
|
||||
label: 'Full Name',
|
||||
required: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
blockName: 'email',
|
||||
blockType: 'email',
|
||||
label: 'Email',
|
||||
required: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
blockName: 'phone',
|
||||
blockType: 'number',
|
||||
label: 'Phone',
|
||||
required: false,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
blockName: 'message',
|
||||
blockType: 'textarea',
|
||||
label: 'Message',
|
||||
required: true,
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
redirect: undefined,
|
||||
submitButtonLabel: 'Submit',
|
||||
title: 'Contact Form',
|
||||
updatedAt: '2023-01-12T21:47:41.374Z',
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CollectionSlug, GlobalSlug, Payload, PayloadRequest, File } from 'payload'
|
||||
|
||||
import { contactForm as contactFormData } from './contact-form'
|
||||
import { contact as contactPageData } from './contact-page'
|
||||
import { home } from './home'
|
||||
import { image1 } from './image-1'
|
||||
@@ -15,8 +14,6 @@ const collections: CollectionSlug[] = [
|
||||
'media',
|
||||
'pages',
|
||||
'posts',
|
||||
'forms',
|
||||
'form-submissions',
|
||||
'search',
|
||||
]
|
||||
const globals: GlobalSlug[] = ['header', 'footer']
|
||||
@@ -257,14 +254,6 @@ export const seed = async ({
|
||||
},
|
||||
})
|
||||
|
||||
payload.logger.info(`— Seeding contact form...`)
|
||||
|
||||
const contactForm = await payload.create({
|
||||
collection: 'forms',
|
||||
depth: 0,
|
||||
data: contactFormData,
|
||||
})
|
||||
|
||||
payload.logger.info(`— Seeding pages...`)
|
||||
|
||||
const [_, contactPage] = await Promise.all([
|
||||
|
||||
52
apps/backend/src/jobs/cleanupAuditLogs.ts
Normal file
52
apps/backend/src/jobs/cleanupAuditLogs.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
/**
|
||||
* 清理 90 天前的稽核日誌
|
||||
* 此任務應該透過 cron job 定期執行(建議每天一次)
|
||||
*/
|
||||
export const cleanupAuditLogs = {
|
||||
slug: 'cleanup-audit-logs',
|
||||
label: '清理 90 天前的稽核日誌',
|
||||
handler: async ({ req }: { req: PayloadRequest }) => {
|
||||
try {
|
||||
// 計算 90 天前的日期
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
// 查找 90 天前的稽核日誌
|
||||
const oldLogs = await req.payload.find({
|
||||
collection: 'audit',
|
||||
where: {
|
||||
createdAt: {
|
||||
less_than: ninetyDaysAgo.toISOString(),
|
||||
},
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1000, // 每次最多處理 1000 筆
|
||||
})
|
||||
|
||||
if (oldLogs.totalDocs > 0) {
|
||||
// 批量刪除舊日誌
|
||||
for (const log of oldLogs.docs) {
|
||||
await req.payload.delete({
|
||||
collection: 'audit',
|
||||
id: log.id,
|
||||
})
|
||||
}
|
||||
|
||||
req.payload.logger.info(`Cleaned up ${oldLogs.totalDocs} audit logs older than 90 days`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleaned up ${oldLogs.totalDocs} audit logs older than 90 days`,
|
||||
}
|
||||
} catch (error) {
|
||||
req.payload.logger.error('Audit log cleanup failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Audit log cleanup failed',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export interface Config {
|
||||
categories: Category;
|
||||
users: User;
|
||||
portfolio: Portfolio;
|
||||
audit: Audit;
|
||||
redirects: Redirect;
|
||||
search: Search;
|
||||
'payload-jobs': PayloadJob;
|
||||
@@ -88,6 +89,7 @@ export interface Config {
|
||||
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
portfolio: PortfolioSelect<false> | PortfolioSelect<true>;
|
||||
audit: AuditSelect<false> | AuditSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
search: SearchSelect<false> | SearchSelect<true>;
|
||||
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||
@@ -112,6 +114,7 @@ export interface Config {
|
||||
};
|
||||
jobs: {
|
||||
tasks: {
|
||||
'cleanup-audit-logs': TaskCleanupAuditLogs;
|
||||
schedulePublish: TaskSchedulePublish;
|
||||
inline: {
|
||||
input: unknown;
|
||||
@@ -213,6 +216,10 @@ export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
heroImage?: (string | null) | Media;
|
||||
/**
|
||||
* Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px
|
||||
*/
|
||||
ogImage?: (string | null) | Media;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
@@ -228,8 +235,13 @@ export interface Post {
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* 顯示在文章列表頁,建議 150-200 字
|
||||
*/
|
||||
excerpt?: string | null;
|
||||
relatedPosts?: (string | Post)[] | null;
|
||||
categories?: (string | Category)[] | null;
|
||||
showInFooter?: boolean | null;
|
||||
meta?: {
|
||||
title?: string | null;
|
||||
/**
|
||||
@@ -248,6 +260,7 @@ export interface Post {
|
||||
| null;
|
||||
slug?: string | null;
|
||||
slugLock?: boolean | null;
|
||||
status?: ('draft' | 'review' | 'published') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
@@ -351,6 +364,22 @@ export interface Media {
|
||||
export interface Category {
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* 用於 URL 或國際化
|
||||
*/
|
||||
nameEn?: string | null;
|
||||
/**
|
||||
* 數字越小越靠前
|
||||
*/
|
||||
order?: number | null;
|
||||
/**
|
||||
* 十六進制顏色碼,例如 #000000
|
||||
*/
|
||||
textColor?: string | null;
|
||||
/**
|
||||
* 十六進制顏色碼,例如 #ffffff
|
||||
*/
|
||||
backgroundColor?: string | null;
|
||||
slug?: string | null;
|
||||
slugLock?: boolean | null;
|
||||
parent?: (string | null) | Category;
|
||||
@@ -372,6 +401,7 @@ export interface Category {
|
||||
export interface User {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
role: 'admin' | 'editor';
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
@@ -561,6 +591,63 @@ export interface Portfolio {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* 系統稽核日誌 - 自動記錄所有重要操作
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "audit".
|
||||
*/
|
||||
export interface Audit {
|
||||
id: string;
|
||||
action: 'login' | 'logout' | 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
|
||||
/**
|
||||
* 受影響的集合名稱
|
||||
*/
|
||||
collection: string;
|
||||
/**
|
||||
* 受影響的文件 ID
|
||||
*/
|
||||
documentId?: string | null;
|
||||
/**
|
||||
* 受影響的文件標題
|
||||
*/
|
||||
documentTitle?: string | null;
|
||||
/**
|
||||
* 執行操作的使用者 ID
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 執行操作的使用者名稱
|
||||
*/
|
||||
userName?: string | null;
|
||||
/**
|
||||
* 執行操作的使用者信箱
|
||||
*/
|
||||
userEmail?: string | null;
|
||||
userRole?: ('admin' | 'editor') | null;
|
||||
/**
|
||||
* IP 位址
|
||||
*/
|
||||
ipAddress?: string | null;
|
||||
/**
|
||||
* 瀏覽器 User Agent
|
||||
*/
|
||||
userAgent?: string | null;
|
||||
/**
|
||||
* 變更內容詳細資訊
|
||||
*/
|
||||
changes?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "redirects".
|
||||
@@ -670,7 +757,7 @@ export interface PayloadJob {
|
||||
| {
|
||||
executedAt: string;
|
||||
completedAt: string;
|
||||
taskSlug: 'inline' | 'schedulePublish';
|
||||
taskSlug: 'inline' | 'cleanup-audit-logs' | 'schedulePublish';
|
||||
taskID: string;
|
||||
input?:
|
||||
| {
|
||||
@@ -703,7 +790,7 @@ export interface PayloadJob {
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
taskSlug?: ('inline' | 'schedulePublish') | null;
|
||||
taskSlug?: ('inline' | 'cleanup-audit-logs' | 'schedulePublish') | null;
|
||||
queue?: string | null;
|
||||
waitUntil?: string | null;
|
||||
processing?: boolean | null;
|
||||
@@ -741,6 +828,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'portfolio';
|
||||
value: string | Portfolio;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'audit';
|
||||
value: string | Audit;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'redirects';
|
||||
value: string | Redirect;
|
||||
@@ -925,9 +1016,12 @@ export interface ArchiveBlockSelect<T extends boolean = true> {
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
heroImage?: T;
|
||||
ogImage?: T;
|
||||
content?: T;
|
||||
excerpt?: T;
|
||||
relatedPosts?: T;
|
||||
categories?: T;
|
||||
showInFooter?: T;
|
||||
meta?:
|
||||
| T
|
||||
| {
|
||||
@@ -945,6 +1039,7 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
};
|
||||
slug?: T;
|
||||
slugLock?: T;
|
||||
status?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
@@ -1048,6 +1143,10 @@ export interface MediaSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface CategoriesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
nameEn?: T;
|
||||
order?: T;
|
||||
textColor?: T;
|
||||
backgroundColor?: T;
|
||||
slug?: T;
|
||||
slugLock?: T;
|
||||
parent?: T;
|
||||
@@ -1068,6 +1167,7 @@ export interface CategoriesSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
role?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
@@ -1107,6 +1207,25 @@ export interface PortfolioSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "audit_select".
|
||||
*/
|
||||
export interface AuditSelect<T extends boolean = true> {
|
||||
action?: T;
|
||||
collection?: T;
|
||||
documentId?: T;
|
||||
documentTitle?: T;
|
||||
userId?: T;
|
||||
userName?: T;
|
||||
userEmail?: T;
|
||||
userRole?: T;
|
||||
ipAddress?: T;
|
||||
userAgent?: T;
|
||||
changes?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "redirects_select".
|
||||
@@ -1351,6 +1470,14 @@ export interface FooterSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskCleanup-audit-logs".
|
||||
*/
|
||||
export interface TaskCleanupAuditLogs {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskSchedulePublish".
|
||||
|
||||
@@ -6,6 +6,7 @@ import sharp from 'sharp' // sharp-import
|
||||
import path from 'path'
|
||||
import { buildConfig, PayloadRequest } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { Audit } from './collections/Audit'
|
||||
import { Categories } from './collections/Categories'
|
||||
import { Media } from './collections/Media'
|
||||
import { Pages } from './collections/Pages'
|
||||
@@ -17,6 +18,7 @@ import { Header } from './Header/config'
|
||||
import { plugins } from './plugins'
|
||||
import { defaultLexical } from '@/fields/defaultLexical'
|
||||
import { getServerSideURL } from './utilities/getURL'
|
||||
import { cleanupAuditLogs } from './jobs/cleanupAuditLogs'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -63,7 +65,7 @@ export default buildConfig({
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI || '',
|
||||
}),
|
||||
collections: [Pages, Posts, Media, Categories, Users, Portfolio],
|
||||
collections: [Pages, Posts, Media, Categories, Users, Portfolio, Audit],
|
||||
cors: [
|
||||
getServerSideURL(),
|
||||
'http://localhost:4321', // Astro dev server
|
||||
@@ -111,6 +113,6 @@ export default buildConfig({
|
||||
return authHeader === `Bearer ${process.env.CRON_SECRET}`
|
||||
},
|
||||
},
|
||||
tasks: [],
|
||||
tasks: [cleanupAuditLogs],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
|
||||
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
|
||||
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
|
||||
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
|
||||
import { seoPlugin } from '@payloadcms/plugin-seo'
|
||||
@@ -7,7 +5,6 @@ import { searchPlugin } from '@payloadcms/plugin-search'
|
||||
import { Plugin } from 'payload'
|
||||
import { revalidateRedirects } from '@/hooks/revalidateRedirects'
|
||||
import { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
|
||||
import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { searchFields } from '@/search/fieldOverrides'
|
||||
import { beforeSyncWithSearch } from '@/search/beforeSync'
|
||||
|
||||
@@ -55,32 +52,6 @@ export const plugins: Plugin[] = [
|
||||
generateTitle,
|
||||
generateURL,
|
||||
}),
|
||||
formBuilderPlugin({
|
||||
fields: {
|
||||
payment: false,
|
||||
},
|
||||
formOverrides: {
|
||||
fields: ({ defaultFields }) => {
|
||||
return defaultFields.map((field) => {
|
||||
if ('name' in field && field.name === 'confirmationMessage') {
|
||||
return {
|
||||
...field,
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
FixedToolbarFeature(),
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
]
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
return field
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
searchPlugin({
|
||||
collections: ['posts'],
|
||||
beforeSync: beforeSyncWithSearch,
|
||||
@@ -90,5 +61,4 @@ export const plugins: Plugin[] = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
payloadCloudPlugin(),
|
||||
]
|
||||
|
||||
11
apps/backend/src/types/audit.ts
Normal file
11
apps/backend/src/types/audit.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 稽核日誌操作類型
|
||||
*/
|
||||
export type AuditAction =
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'publish'
|
||||
| 'unpublish'
|
||||
110
apps/backend/src/utilities/auditLogger.ts
Normal file
110
apps/backend/src/utilities/auditLogger.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
import { AuditAction } from '@/types/audit'
|
||||
|
||||
interface AuditLogData {
|
||||
action: AuditAction
|
||||
collection: string
|
||||
documentId?: string
|
||||
documentTitle?: string
|
||||
userId: string
|
||||
userName?: string
|
||||
userEmail?: string
|
||||
userRole?: string
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
changes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立稽核日誌
|
||||
* 此函數用於記錄系統中的重要操作,包括登入/登出、內容變更等
|
||||
*/
|
||||
export const auditLogger = async (req: PayloadRequest, data: AuditLogData): Promise<void> => {
|
||||
try {
|
||||
// 從請求中獲取 IP 和 User Agent
|
||||
const ipAddress = req.headers?.get('x-forwarded-for')?.split(',')[0] ||
|
||||
req.headers?.get('x-real-ip') ||
|
||||
undefined
|
||||
const userAgent = req.headers?.get('user-agent') || undefined
|
||||
|
||||
await req.payload.create({
|
||||
collection: 'audit',
|
||||
data: {
|
||||
action: data.action,
|
||||
collection: data.collection,
|
||||
documentId: data.documentId,
|
||||
documentTitle: data.documentTitle,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
userEmail: data.userEmail,
|
||||
userRole: data.userRole,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
changes: data.changes,
|
||||
},
|
||||
// 不觸發稽核日誌的稽核日誌(避免無限循環)
|
||||
depth: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
// 記錄錯誤但不中斷主要流程
|
||||
console.error('Audit log creation failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄登入事件
|
||||
*/
|
||||
export const logLogin = async (req: PayloadRequest, userId: string): Promise<void> => {
|
||||
const user = await req.payload.findByID({
|
||||
collection: 'users',
|
||||
id: userId,
|
||||
})
|
||||
|
||||
await auditLogger(req, {
|
||||
action: 'login',
|
||||
collection: 'users',
|
||||
documentId: userId,
|
||||
userId,
|
||||
userName: user.name as string,
|
||||
userEmail: user.email as string,
|
||||
userRole: String(user.role ?? ''),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄登出事件
|
||||
*/
|
||||
export const logLogout = async (req: PayloadRequest, userId: string): Promise<void> => {
|
||||
await auditLogger(req, {
|
||||
action: 'logout',
|
||||
collection: 'users',
|
||||
documentId: userId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄文件變更(創建、更新、刪除)
|
||||
*/
|
||||
export const logDocumentChange = async (
|
||||
req: PayloadRequest,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
collection: string,
|
||||
docId?: string,
|
||||
docTitle?: string,
|
||||
changes?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
if (!req.user) return
|
||||
|
||||
await auditLogger(req, {
|
||||
action: operation,
|
||||
collection,
|
||||
documentId: docId,
|
||||
documentTitle: docTitle,
|
||||
userId: req.user.id,
|
||||
userName: req.user.name as string,
|
||||
userEmail: req.user.email as string,
|
||||
userRole: String(req.user.role ?? ''),
|
||||
changes,
|
||||
})
|
||||
}
|
||||
21
apps/backend/tests/k6/.env.example
Normal file
21
apps/backend/tests/k6/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# K6 Load Testing Environment Variables
|
||||
# Copy this file to .env.k6 and customize for your environment
|
||||
|
||||
# Target Server Configuration
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# Admin Credentials (for admin-operations.js)
|
||||
ADMIN_EMAIL=admin@enchun.tw
|
||||
ADMIN_PASSWORD=your-secure-password-here
|
||||
|
||||
# Optional: Override default load profile
|
||||
# STAGED_USERS=100
|
||||
# STAGED_DURATION=10m
|
||||
|
||||
# Optional: Staging environment
|
||||
# BASE_URL=https://staging.enchun.tw
|
||||
# ADMIN_EMAIL=admin@staging.enchun.tw
|
||||
|
||||
# Optional: Production environment (use with caution!)
|
||||
# BASE_URL=https://www.enchun.tw
|
||||
# ADMIN_EMAIL=admin@enchun.tw
|
||||
154
apps/backend/tests/k6/.github-workflow-example.yml
Normal file
154
apps/backend/tests/k6/.github-workflow-example.yml
Normal file
@@ -0,0 +1,154 @@
|
||||
# K6 Load Testing - GitHub Actions Workflow Example
|
||||
# Copy this to .github/workflows/load-tests.yml
|
||||
|
||||
name: Load Tests
|
||||
|
||||
on:
|
||||
# Run daily at 2 AM UTC
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
|
||||
# Run on demand
|
||||
workflow_dispatch:
|
||||
|
||||
# Run after deployment to staging
|
||||
workflow_run:
|
||||
workflows: ["Deploy to Staging"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
public-browsing:
|
||||
name: Public Browsing (100 users)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup k6
|
||||
run: |
|
||||
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||
|
||||
- name: Wait for server
|
||||
run: |
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -sSf ${{ vars.STAGING_URL }} > /dev/null; do sleep 2; done'
|
||||
echo "Server is ready!"
|
||||
|
||||
- name: Run Public Browsing Test
|
||||
run: |
|
||||
k6 run \
|
||||
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||
--out json=public-browsing-results.json \
|
||||
apps/backend/tests/k6/public-browsing.js
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: public-browsing-results
|
||||
path: public-browsing-results.json
|
||||
retention-days: 30
|
||||
|
||||
api-performance:
|
||||
name: API Performance (50 users)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup k6
|
||||
run: |
|
||||
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||
|
||||
- name: Run API Performance Test
|
||||
run: |
|
||||
k6 run \
|
||||
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||
--out json=api-performance-results.json \
|
||||
apps/backend/tests/k6/api-performance.js
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-performance-results
|
||||
path: api-performance-results.json
|
||||
retention-days: 30
|
||||
|
||||
admin-operations:
|
||||
name: Admin Operations (20 users)
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on manual trigger or schedule, not on every deployment
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup k6
|
||||
run: |
|
||||
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||
|
||||
- name: Run Admin Operations Test
|
||||
run: |
|
||||
k6 run \
|
||||
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||
--env ADMIN_EMAIL=${{ secrets.TEST_ADMIN_EMAIL }} \
|
||||
--env ADMIN_PASSWORD=${{ secrets.TEST_ADMIN_PASSWORD }} \
|
||||
--out json=admin-operations-results.json \
|
||||
apps/backend/tests/k6/admin-operations.js
|
||||
env:
|
||||
# Don't log passwords
|
||||
K6_NO_LOG_USAGE: true
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-operations-results
|
||||
path: admin-operations-results.json
|
||||
retention-days: 30
|
||||
|
||||
# Optional: Generate and publish HTML reports
|
||||
generate-reports:
|
||||
name: Generate HTML Reports
|
||||
needs: [public-browsing, api-performance]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: results
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install k6-reporter
|
||||
run: npm install -g k6-reporter
|
||||
|
||||
- name: Generate Reports
|
||||
run: |
|
||||
mkdir -p reports
|
||||
k6-reporter results/public-browsing-results/public-browsing-results.json --output reports/public-browsing.html
|
||||
k6-reporter results/api-performance-results/api-performance-results.json --output reports/api-performance.html
|
||||
|
||||
- name: Upload Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: html-reports
|
||||
path: reports/
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment PR with Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const publicReport = fs.readFileSync('reports/public-browsing.html', 'utf8');
|
||||
// Add comment to PR with summary...
|
||||
101
apps/backend/tests/k6/QUICKSTART.md
Normal file
101
apps/backend/tests/k6/QUICKSTART.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# K6 Load Testing - Quick Start Guide
|
||||
|
||||
## 5-Minute Setup
|
||||
|
||||
### Step 1: Install k6
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install k6
|
||||
|
||||
# Verify installation
|
||||
k6 version
|
||||
```
|
||||
|
||||
### Step 2: Start Your Backend
|
||||
|
||||
```bash
|
||||
# In one terminal
|
||||
cd /Users/pukpuk/Dev/website-enchun-mgr
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Step 3: Run Your First Test
|
||||
|
||||
```bash
|
||||
# In another terminal
|
||||
cd apps/backend
|
||||
|
||||
# Run public browsing test (simplest - no auth needed)
|
||||
k6 run tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
That's it! You should see output showing 100 virtual users browsing your site.
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
# Public browsing (100 users)
|
||||
k6 run tests/k6/public-browsing.js
|
||||
|
||||
# API performance (50 users)
|
||||
k6 run tests/k6/api-performance.js
|
||||
|
||||
# Admin operations (20 users) - requires admin credentials
|
||||
k6 run --env ADMIN_EMAIL=your@email.com --env ADMIN_PASSWORD=yourpassword \
|
||||
tests/k6/admin-operations.js
|
||||
```
|
||||
|
||||
### Test Against Staging
|
||||
|
||||
```bash
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
### Generate Report
|
||||
|
||||
```bash
|
||||
# Generate JSON output
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
|
||||
# Convert to HTML (requires k6-reporter)
|
||||
npm install -g k6-reporter
|
||||
k6-reporter results.json --output results.html
|
||||
open results.html
|
||||
```
|
||||
|
||||
## Understanding Results
|
||||
|
||||
Look for these key metrics:
|
||||
|
||||
```
|
||||
✓ http_req_duration..............: avg=185ms p(95)=420ms
|
||||
✓ http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||
✓ checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||
```
|
||||
|
||||
**What to check:**
|
||||
- `p(95)` should be < 500ms
|
||||
- `http_req_failed` should be < 1%
|
||||
- `checks` should be > 99%
|
||||
|
||||
## Common Issues
|
||||
|
||||
**"connect attempt failed"**
|
||||
→ Make sure your backend is running (pnpm dev)
|
||||
|
||||
**"login failed" in admin tests**
|
||||
→ Set correct admin credentials via environment variables
|
||||
|
||||
**High error rate**
|
||||
→ Reduce VUs: `k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js`
|
||||
|
||||
## Need Help?
|
||||
|
||||
See the full README: `tests/k6/README.md`
|
||||
|
||||
---
|
||||
|
||||
**Happy Testing!** 🚀
|
||||
294
apps/backend/tests/k6/README.md
Normal file
294
apps/backend/tests/k6/README.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# K6 Load Testing Framework
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains load testing scripts for the Enchun CMS backend using k6. The tests are designed to validate the non-functional requirement NFR4:
|
||||
|
||||
- **Target:** p95 response time < 500ms
|
||||
- **Error rate:** < 1%
|
||||
- **Concurrent users:** 100
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Install k6:**
|
||||
```bash
|
||||
# macOS
|
||||
brew install k6
|
||||
|
||||
# Linux
|
||||
sudo apt-get install k6
|
||||
|
||||
# Windows (using Chocolatey)
|
||||
choco install k6
|
||||
```
|
||||
|
||||
2. **Set up environment:**
|
||||
```bash
|
||||
cp .env.example .env.k6
|
||||
# Edit .env.k6 with your test environment credentials
|
||||
```
|
||||
|
||||
## Test Scripts
|
||||
|
||||
### 1. Public Browsing Test (`public-browsing.js`)
|
||||
|
||||
Simulates 100 concurrent users browsing public pages:
|
||||
- Homepage
|
||||
- About page
|
||||
- Solutions page
|
||||
- Portfolio list
|
||||
- Blog list
|
||||
- Contact page
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
**Expected Results:**
|
||||
- p95 response time < 500ms
|
||||
- Error rate < 1%
|
||||
- 100 concurrent users sustained for 2 minutes
|
||||
|
||||
### 2. Admin Operations Test (`admin-operations.js`)
|
||||
|
||||
Simulates 20 concurrent admin users performing:
|
||||
- Login
|
||||
- List pages/posts
|
||||
- Create/edit content
|
||||
- Delete content
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
k6 run --env BASE_URL=http://localhost:3000 \
|
||||
--env ADMIN_EMAIL=admin@example.com \
|
||||
--env ADMIN_PASSWORD=password123 \
|
||||
tests/k6/admin-operations.js
|
||||
```
|
||||
|
||||
**Expected Results:**
|
||||
- p95 response time < 500ms
|
||||
- Error rate < 1%
|
||||
- 20 concurrent users sustained for 3 minutes
|
||||
|
||||
### 3. API Performance Test (`api-performance.js`)
|
||||
|
||||
Tests specific API endpoints:
|
||||
- GraphQL API queries
|
||||
- REST API endpoints
|
||||
- Global API endpoint
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
**Expected Results:**
|
||||
- p95 response time < 300ms (faster for API)
|
||||
- Error rate < 0.5%
|
||||
- Throughput > 100 requests/second
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `BASE_URL` | Target server URL | `http://localhost:3000` | Yes |
|
||||
| `ADMIN_EMAIL` | Admin user email | - | For admin tests |
|
||||
| `ADMIN_PASSWORD` | Admin user password | - | For admin tests |
|
||||
| `VUS` | Number of virtual users | Varies per test | No |
|
||||
| `DURATION` | Test duration | Varies per test | No |
|
||||
|
||||
### Load Profiles
|
||||
|
||||
Each test uses different load profiles:
|
||||
|
||||
| Test | Virtual Users | Duration | Ramp Up |
|
||||
|------|--------------|----------|---------|
|
||||
| Public Browsing | 100 | 2m | 30s |
|
||||
| Admin Operations | 20 | 3m | 30s |
|
||||
| API Performance | 50 | 5m | 1m |
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run Single Test
|
||||
|
||||
```bash
|
||||
# Basic run
|
||||
k6 run tests/k6/public-browsing.js
|
||||
|
||||
# With custom environment
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
|
||||
# With custom stages
|
||||
k6 run --env STAGED_USERS=200 --env STAGED_DURATION=10m tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
# Using the npm script
|
||||
pnpm test:load
|
||||
|
||||
# Or manually
|
||||
k6 run tests/k6/public-browsing.js
|
||||
k6 run tests/k6/admin-operations.js
|
||||
k6 run tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
### Run with Output Options
|
||||
|
||||
```bash
|
||||
# Generate JSON report
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
|
||||
# Generate HTML report (requires k6-reporter)
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
k6-reporter results.json --output results.html
|
||||
```
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
### Key Metrics
|
||||
|
||||
1. **Response Time (p95):** 95th percentile response time
|
||||
- ✅ Pass: < 500ms
|
||||
- ❌ Fail: >= 500ms
|
||||
|
||||
2. **Error Rate:** Percentage of failed requests
|
||||
- ✅ Pass: < 1%
|
||||
- ❌ Fail: >= 1%
|
||||
|
||||
3. **Throughput:** Requests per second
|
||||
- ✅ Pass: > 100 req/s for API, > 50 req/s for pages
|
||||
- ❌ Fail: Below threshold
|
||||
|
||||
4. **Virtual Users (VUs):** Active concurrent users
|
||||
- Should sustain target VUs for the full duration
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
✓ Status is 200
|
||||
✓ Response time < 500ms
|
||||
✓ Page content loaded
|
||||
|
||||
checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||
data_received..................: 15 MB 125 kB/s
|
||||
data_sent......................: 2.1 MB 18 kB/s
|
||||
http_req_blocked...............: avg=1.2ms min=0.5µs med=1µs max=125ms
|
||||
http_req_connecting............: avg=500µs min=0s med=0s max=45ms
|
||||
http_req_duration..............: avg=185.3ms min=45ms med=150ms max=850ms p(95)=420ms
|
||||
http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||
http_req_receiving.............: avg=15ms min=10µs med=50µs max=250ms
|
||||
http_req_sending...............: avg=50µs min=5µs med=20µs max=5ms
|
||||
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s
|
||||
http_req_waiting...............: avg=170ms min=40ms med=140ms max=800ms
|
||||
http_reqs......................: 12000 100 req/s
|
||||
iteration_duration.............: avg=5.2s min=1.2s med=5s max=12s
|
||||
iterations.....................: 2400 20 /s
|
||||
vus............................: 100 min=100 max=100
|
||||
vus_max........................: 100 min=100 max=100
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Load Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Daily at 2 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
k6:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: grafana/k6-action@v0.3.1
|
||||
with:
|
||||
filename: tests/k6/public-browsing.js
|
||||
env:
|
||||
BASE_URL: ${{ secrets.STAGING_URL }}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Error:** `connect attempt failed`
|
||||
|
||||
**Solution:**
|
||||
- Ensure the backend server is running
|
||||
- Check `BASE_URL` is correct
|
||||
- Verify firewall settings
|
||||
|
||||
### High Error Rate
|
||||
|
||||
**Error:** `http_req_failed > 1%`
|
||||
|
||||
**Possible Causes:**
|
||||
1. Server overload (reduce VUs)
|
||||
2. Database connection issues
|
||||
3. Authentication failures
|
||||
4. Network issues
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
# Run with fewer users
|
||||
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||
|
||||
# Check server logs
|
||||
pnpm dev
|
||||
# In another terminal, watch logs while tests run
|
||||
```
|
||||
|
||||
### Slow Response Times
|
||||
|
||||
**Error:** `p(95) >= 500ms`
|
||||
|
||||
**Possible Causes:**
|
||||
1. Unoptimized database queries
|
||||
2. Missing indexes
|
||||
3. Large payloads
|
||||
4. Server resource constraints
|
||||
|
||||
**Next Steps:**
|
||||
1. Review database queries
|
||||
2. Check caching strategies
|
||||
3. Optimize images/assets
|
||||
4. Scale server resources
|
||||
|
||||
## Performance Baseline
|
||||
|
||||
Initial baseline (to be established):
|
||||
|
||||
| Test | p95 (ms) | Error Rate | Throughput |
|
||||
|------|----------|------------|------------|
|
||||
| Public Browsing | TBD | TBD | TBD |
|
||||
| Admin Operations | TBD | TBD | TBD |
|
||||
| API Performance | TBD | TBD | TBD |
|
||||
|
||||
Update this table after first test run to establish baseline.
|
||||
|
||||
## Resources
|
||||
|
||||
- [k6 Documentation](https://k6.io/docs/)
|
||||
- [k6 Metrics](https://k6.io/docs/using-k6/metrics/)
|
||||
- [Payload CMS Performance](https://payloadcms.com/docs/admin/configuration)
|
||||
- [Web Vitals](https://web.dev/vitals/)
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Review and update test scenarios quarterly
|
||||
- Adjust load profiles based on real traffic patterns
|
||||
- Update thresholds based on business requirements
|
||||
- Add new tests for new features
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-01-31
|
||||
**Owner:** QA Team
|
||||
364
apps/backend/tests/k6/TESTING-GUIDE.md
Normal file
364
apps/backend/tests/k6/TESTING-GUIDE.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Load Testing Execution Guide
|
||||
|
||||
## Test Execution Checklist
|
||||
|
||||
### Pre-Test Requirements
|
||||
|
||||
- [ ] Backend server is running (`pnpm dev`)
|
||||
- [ ] Database is accessible
|
||||
- [ ] k6 is installed (`k6 version`)
|
||||
- [ ] Environment variables are configured
|
||||
|
||||
### Verification Test
|
||||
|
||||
Run the verification script first to ensure everything is set up correctly:
|
||||
|
||||
```bash
|
||||
k6 run tests/k6/verify-setup.js
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
=== K6 Setup Verification ===
|
||||
Target: http://localhost:3000
|
||||
Home page status: 200
|
||||
API status: 200
|
||||
Pages API response time: 123ms
|
||||
✓ Server is reachable
|
||||
✓ Home page responds
|
||||
✓ API endpoint responds
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Public Browsing Test
|
||||
|
||||
**Purpose:** Verify public pages can handle 100 concurrent users
|
||||
|
||||
**Prerequisites:**
|
||||
- Backend running
|
||||
- Public pages accessible
|
||||
|
||||
**Execution:**
|
||||
```bash
|
||||
# Local testing
|
||||
pnpm test:load
|
||||
|
||||
# With custom URL
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/public-browsing.js
|
||||
|
||||
# Staging
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
**Success Criteria:**
|
||||
- p95 response time < 500ms
|
||||
- Error rate < 1%
|
||||
- 100 concurrent users sustained for 2 minutes
|
||||
|
||||
**What It Tests:**
|
||||
- Homepage rendering
|
||||
- Page navigation
|
||||
- Static content delivery
|
||||
- Database read operations
|
||||
|
||||
---
|
||||
|
||||
### 2. API Performance Test
|
||||
|
||||
**Purpose:** Verify API endpoints meet performance targets
|
||||
|
||||
**Prerequisites:**
|
||||
- Backend running
|
||||
- API endpoints accessible
|
||||
|
||||
**Execution:**
|
||||
```bash
|
||||
# Local testing
|
||||
pnpm test:load:api
|
||||
|
||||
# With custom URL
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/api-performance.js
|
||||
|
||||
# Staging
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
**Success Criteria:**
|
||||
- p95 response time < 300ms
|
||||
- Error rate < 0.5%
|
||||
- Throughput > 100 req/s
|
||||
|
||||
**What It Tests:**
|
||||
- REST API endpoints
|
||||
- GraphQL queries
|
||||
- Authentication endpoints
|
||||
- Concurrent API requests
|
||||
|
||||
---
|
||||
|
||||
### 3. Admin Operations Test
|
||||
|
||||
**Purpose:** Verify admin panel can handle 20 concurrent users
|
||||
|
||||
**Prerequisites:**
|
||||
- Backend running
|
||||
- Valid admin credentials
|
||||
|
||||
**Execution:**
|
||||
```bash
|
||||
# Local testing
|
||||
k6 run \
|
||||
--env ADMIN_EMAIL=admin@enchun.tw \
|
||||
--env ADMIN_PASSWORD=yourpassword \
|
||||
tests/k6/admin-operations.js
|
||||
|
||||
# Or use npm script
|
||||
ADMIN_EMAIL=admin@enchun.tw ADMIN_PASSWORD=yourpassword \
|
||||
pnpm test:load:admin
|
||||
```
|
||||
|
||||
**Success Criteria:**
|
||||
- p95 response time < 700ms
|
||||
- Error rate < 1%
|
||||
- 20 concurrent users sustained for 3 minutes
|
||||
|
||||
**What It Tests:**
|
||||
- Login/authentication
|
||||
- CRUD operations
|
||||
- Admin panel performance
|
||||
- Database write operations
|
||||
|
||||
**Warning:** This test creates draft posts in the database. Clean up manually after testing.
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Strategy
|
||||
|
||||
### Phase 1: Development Testing
|
||||
|
||||
Run tests locally during development with low load:
|
||||
|
||||
```bash
|
||||
# Quick smoke test (10 users)
|
||||
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||
|
||||
# API smoke test (5 users)
|
||||
k6 run --env STAGED_USERS=5 tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
### Phase 2: Pre-Deployment Testing
|
||||
|
||||
Run full test suite against staging:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test:load:all
|
||||
|
||||
# Or individual tests with full load
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/api-performance.js
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw \
|
||||
--env ADMIN_EMAIL=$STAGING_ADMIN_EMAIL \
|
||||
--env ADMIN_PASSWORD=$STAGING_ADMIN_PASSWORD \
|
||||
tests/k6/admin-operations.js
|
||||
```
|
||||
|
||||
### Phase 3: Production Monitoring
|
||||
|
||||
Schedule automated tests (via GitHub Actions or cron):
|
||||
|
||||
- Daily: Public browsing and API tests
|
||||
- Weekly: Full test suite including admin operations
|
||||
- On-demand: Before major releases
|
||||
|
||||
---
|
||||
|
||||
## Result Analysis
|
||||
|
||||
### Key Metrics to Check
|
||||
|
||||
1. **p95 Response Time**
|
||||
- Public pages: < 500ms ✅
|
||||
- API endpoints: < 300ms ✅
|
||||
- Admin operations: < 700ms ✅
|
||||
|
||||
2. **Error Rate**
|
||||
- Public: < 1% ✅
|
||||
- API: < 0.5% ✅
|
||||
- Admin: < 1% ✅
|
||||
|
||||
3. **Throughput**
|
||||
- API: > 100 req/s ✅
|
||||
- Pages: > 50 req/s ✅
|
||||
|
||||
4. **Virtual Users (VUs)**
|
||||
- Should sustain target VUs for full duration
|
||||
- No drops or connection errors
|
||||
|
||||
### Sample Output Analysis
|
||||
|
||||
```
|
||||
✓ http_req_duration..............: avg=185ms p(95)=420ms
|
||||
✓ http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||
✓ checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||
iterations.....................: 2400 20 /s
|
||||
vus............................: 100 min=100 max=100
|
||||
```
|
||||
|
||||
**This result shows:**
|
||||
- ✅ p95 = 420ms (< 500ms threshold)
|
||||
- ✅ Error rate = 0% (< 1% threshold)
|
||||
- ✅ All checks passed
|
||||
- ✅ Sustained 100 VUs
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Issue: Connection Refused
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
ERRO[0000] GoError: dial tcp 127.0.0.1:3000: connect: connection refused
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure backend is running: `pnpm dev`
|
||||
2. Check port is correct: `--env BASE_URL=http://localhost:3000`
|
||||
3. Verify firewall isn't blocking connections
|
||||
|
||||
---
|
||||
|
||||
### Issue: High Error Rate (> 1%)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
✗ http_req_failed................: 2.50% ✓ 11700 ✗ 300
|
||||
```
|
||||
|
||||
**Common Causes:**
|
||||
1. Server overloaded → Reduce VUs
|
||||
2. Database connection issues → Check DB logs
|
||||
3. Missing authentication → Check credentials
|
||||
4. Invalid URLs → Verify BASE_URL
|
||||
|
||||
**Solutions:**
|
||||
```bash
|
||||
# Reduce load to diagnose
|
||||
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||
|
||||
# Check server logs in parallel
|
||||
tail -f logs/backend.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Slow Response Times (p95 >= 500ms)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
http_req_duration..............: avg=450ms p(95)=850ms
|
||||
```
|
||||
|
||||
**Common Causes:**
|
||||
1. Unoptimized database queries
|
||||
2. Missing database indexes
|
||||
3. Large payload sizes
|
||||
4. Server resource constraints
|
||||
|
||||
**Solutions:**
|
||||
1. Check database query performance
|
||||
2. Review database indexes
|
||||
3. Optimize images/assets
|
||||
4. Scale server resources
|
||||
5. Enable caching
|
||||
|
||||
---
|
||||
|
||||
### Issue: Login Failed (Admin Test)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
✗ login successful
|
||||
status: 401
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Verify admin credentials are correct
|
||||
2. Check admin user exists in database
|
||||
3. Ensure admin user has correct role
|
||||
4. Try logging in via admin panel first
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization Tips
|
||||
|
||||
Based on test results, you may need to:
|
||||
|
||||
1. **Add Database Indexes**
|
||||
```javascript
|
||||
// Example: Index on frequently queried fields
|
||||
Posts.createIndex({ title: 1 });
|
||||
Posts.createIndex({ status: 1, createdAt: -1 });
|
||||
```
|
||||
|
||||
2. **Enable Caching**
|
||||
- Cache global API responses
|
||||
- Cache public pages
|
||||
- Use CDN for static assets
|
||||
|
||||
3. **Optimize Images**
|
||||
- Use WebP format
|
||||
- Implement lazy loading
|
||||
- Serve responsive images
|
||||
|
||||
4. **Database Optimization**
|
||||
- Limit query depth where possible
|
||||
- Use projection to reduce payload size
|
||||
- Implement pagination
|
||||
|
||||
5. **Scale Resources**
|
||||
- Increase server memory/CPU
|
||||
- Use database connection pooling
|
||||
- Implement load balancing
|
||||
|
||||
---
|
||||
|
||||
## Reporting
|
||||
|
||||
### Generate Reports
|
||||
|
||||
```bash
|
||||
# JSON report
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
|
||||
# HTML report
|
||||
npm install -g k6-reporter
|
||||
k6-reporter results.json --output results.html
|
||||
open results.html
|
||||
```
|
||||
|
||||
### Share Results
|
||||
|
||||
Include in your PR/daily standup:
|
||||
- p95 response time
|
||||
- Error rate
|
||||
- Throughput
|
||||
- Any issues found
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Run tests regularly** - Catch regressions early
|
||||
2. **Test on staging first** - Avoid breaking production
|
||||
3. **Monitor trends** - Track performance over time
|
||||
4. **Share results** - Keep team informed
|
||||
5. **Update baselines** - Adjust as system evolves
|
||||
6. **Don't ignore failures** - Investigate all issues
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-01-31
|
||||
**Owner:** QA Team
|
||||
232
apps/backend/tests/k6/admin-operations.js
Normal file
232
apps/backend/tests/k6/admin-operations.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Admin Operations Load Test
|
||||
*
|
||||
* Simulates 20 concurrent admin users performing management operations
|
||||
* Tests:
|
||||
* - Login
|
||||
* - List collections (Pages, Posts, Portfolio)
|
||||
* - View/edit items
|
||||
* - Create new items
|
||||
* - Delete items
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 700ms (slightly more lenient for admin)
|
||||
* - Error rate < 1%
|
||||
* - 20 concurrent users sustained for 3 minutes
|
||||
*/
|
||||
|
||||
import { check, group } from 'k6';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { AuthHelper, ApiHelper, thinkTime, testData } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.adminOperations,
|
||||
thresholds: thresholdGroups.admin,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// Store auth token per VU
|
||||
let authToken = null;
|
||||
let apiHelper = null;
|
||||
|
||||
/**
|
||||
* Setup and login - runs once per VU
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== Admin Operations Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log(`Admin: ${config.adminEmail}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Login function
|
||||
*/
|
||||
function login() {
|
||||
const auth = new AuthHelper(config.baseUrl);
|
||||
const { success } = auth.login(config.adminEmail, config.adminPassword);
|
||||
|
||||
if (!success) {
|
||||
console.error('Login failed!');
|
||||
return null;
|
||||
}
|
||||
|
||||
apiHelper = new ApiHelper(config.baseUrl);
|
||||
apiHelper.setToken(auth.token);
|
||||
return auth.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Login if not authenticated
|
||||
if (!authToken) {
|
||||
group('Admin Login', () => {
|
||||
authToken = login();
|
||||
thinkTime(1, 2);
|
||||
});
|
||||
|
||||
if (!authToken) {
|
||||
// Cannot proceed without auth
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 1: Browse collections (read operations)
|
||||
group('List Collections', () => {
|
||||
// List pages
|
||||
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
|
||||
// List posts
|
||||
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
|
||||
// List portfolio
|
||||
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
});
|
||||
|
||||
// Scenario 2: View specific items (read operations)
|
||||
group('View Items', () => {
|
||||
// Try to view first item from each collection
|
||||
try {
|
||||
const pages = apiHelper.get('/pages', { limit: 1, depth: 0 });
|
||||
if (pages.status === 200 && pages.json('totalDocs') > 0) {
|
||||
const firstId = pages.json('docs')[0].id;
|
||||
apiHelper.get(`/pages/${firstId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
|
||||
const posts = apiHelper.get('/posts', { limit: 1, depth: 0 });
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const firstId = posts.json('docs')[0].id;
|
||||
apiHelper.get(`/posts/${firstId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
// Items might not exist
|
||||
console.log('No items to view:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 3: Create new content (write operations)
|
||||
group('Create Content', () => {
|
||||
// 20% chance to create a test post
|
||||
if (Math.random() < 0.2) {
|
||||
const newPost = {
|
||||
title: `Load Test Post ${testData.string(6)}`,
|
||||
content: testData.content(2),
|
||||
status: 'draft', // Save as draft to avoid publishing
|
||||
};
|
||||
|
||||
const res = apiHelper.post('/posts', newPost);
|
||||
|
||||
if (res.status === 201 || res.status === 200) {
|
||||
const postId = res.json('doc')?.id;
|
||||
|
||||
// Store for potential cleanup (in real scenario)
|
||||
if (postId) {
|
||||
console.log(`Created post: ${postId}`);
|
||||
}
|
||||
}
|
||||
|
||||
thinkTime(2, 3);
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 4: Update content (write operations)
|
||||
group('Update Content', () => {
|
||||
// 30% chance to update a post
|
||||
if (Math.random() < 0.3) {
|
||||
try {
|
||||
// Get a random post
|
||||
const posts = apiHelper.get('/posts', { limit: 20, depth: 0, where: { status: { equals: 'draft' } } });
|
||||
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const docs = posts.json('docs');
|
||||
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||
const postId = randomPost.id;
|
||||
|
||||
// Update the post
|
||||
const updateData = {
|
||||
title: `Updated ${randomPost.title}`,
|
||||
};
|
||||
|
||||
apiHelper.put(`/posts/${postId}`, updateData);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Update failed:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 5: Delete test content (cleanup operations)
|
||||
group('Delete Content', () => {
|
||||
// 10% chance to delete a draft post
|
||||
if (Math.random() < 0.1) {
|
||||
try {
|
||||
// Get draft posts (likely from load test)
|
||||
const posts = apiHelper.get('/posts', {
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
where: {
|
||||
status: { equals: 'draft' },
|
||||
title: { like: 'Load Test Post' },
|
||||
},
|
||||
});
|
||||
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const docs = posts.json('docs');
|
||||
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||
const postId = randomPost.id;
|
||||
|
||||
// Delete the post
|
||||
apiHelper.delete(`/posts/${postId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Delete failed:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 6: Use GraphQL API
|
||||
group('GraphQL Operations', () => {
|
||||
// 40% chance to use GraphQL
|
||||
if (Math.random() < 0.4) {
|
||||
const query = `
|
||||
query {
|
||||
Posts(limit: 5) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
apiHelper.graphql(query);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Think time before next iteration
|
||||
thinkTime(3, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function - runs once after test
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== Admin Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 700ms');
|
||||
console.log('- Error rate < 1%');
|
||||
console.log('- 20 concurrent admin users sustained');
|
||||
console.log('Note: Any draft posts created were left in the system for manual review');
|
||||
}
|
||||
230
apps/backend/tests/k6/api-performance.js
Normal file
230
apps/backend/tests/k6/api-performance.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* API Performance Load Test
|
||||
*
|
||||
* Tests specific API endpoints for performance
|
||||
* Tests:
|
||||
* - REST API endpoints (Pages, Posts, Portfolio, Categories)
|
||||
* - GraphQL API queries
|
||||
* - Global API endpoint
|
||||
* - Authentication endpoints
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 300ms (faster for API)
|
||||
* - Error rate < 0.5% (stricter for API)
|
||||
* - Throughput > 100 requests/second
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { ApiHelper, testData, thinkTime } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.apiPerformance,
|
||||
thresholds: thresholdGroups.api,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// API helper (no auth needed for public endpoints)
|
||||
const apiHelper = new ApiHelper(config.baseUrl);
|
||||
|
||||
/**
|
||||
* Setup function
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== API Performance Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Scenario 1: Global API endpoint (metadata)
|
||||
group('Global API', () => {
|
||||
const res = apiHelper.get('/global');
|
||||
|
||||
check(res, {
|
||||
'has global data': (r) => {
|
||||
try {
|
||||
const body = r.json();
|
||||
return body !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
thinkTime(0.1, 0.3); // Minimal think time for API
|
||||
});
|
||||
|
||||
// Scenario 2: Pages API
|
||||
group('Pages API', () => {
|
||||
// List pages
|
||||
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||
|
||||
// Try to get a specific page
|
||||
try {
|
||||
const list = apiHelper.get('/pages', { limit: 1, depth: 0, page: 1 });
|
||||
if (list.status === 200 && list.json('totalDocs') > 0) {
|
||||
const firstId = list.json('docs')[0].id;
|
||||
apiHelper.get(`/pages/${firstId}`, { depth: 1 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Page might not exist
|
||||
}
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 3: Posts API
|
||||
group('Posts API', () => {
|
||||
// List posts
|
||||
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||
|
||||
// List with pagination
|
||||
apiHelper.get('/posts', { limit: 20, depth: 0, page: 1 });
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 4: Portfolio API
|
||||
group('Portfolio API', () => {
|
||||
// List portfolio items
|
||||
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||
|
||||
// Filter by category (if applicable)
|
||||
try {
|
||||
const categories = apiHelper.get('/categories', { limit: 1, depth: 0 });
|
||||
if (categories.status === 200 && categories.json('totalDocs') > 0) {
|
||||
const categoryId = categories.json('docs')[0].id;
|
||||
apiHelper.get('/portfolio', {
|
||||
limit: 10,
|
||||
depth: 1,
|
||||
where: {
|
||||
category: { equals: categoryId },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Categories might not exist
|
||||
}
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 5: Categories API
|
||||
group('Categories API', () => {
|
||||
// List all categories
|
||||
apiHelper.get('/categories', { limit: 10, depth: 1 });
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 6: GraphQL API
|
||||
group('GraphQL API', () => {
|
||||
// Query 1: Simple list
|
||||
const simpleQuery = `
|
||||
query {
|
||||
Posts(limit: 5) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
apiHelper.graphql(simpleQuery);
|
||||
|
||||
thinkTime(0.1, 0.2);
|
||||
|
||||
// Query 2: With relationships
|
||||
const complexQuery = `
|
||||
query {
|
||||
Posts(limit: 3, depth: 2) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
content
|
||||
category {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
apiHelper.graphql(complexQuery);
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 7: Authentication endpoints
|
||||
group('Auth API', () => {
|
||||
// Note: These will fail with invalid credentials, but test the endpoint response
|
||||
const loginRes = http.post(`${config.baseUrl}/api/users/login`, JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword',
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
check(loginRes, {
|
||||
'login responds': (r) => [200, 400, 401].includes(r.status),
|
||||
'login responds quickly': (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
thinkTime(0.1, 0.2);
|
||||
});
|
||||
|
||||
// Scenario 8: Concurrent API requests
|
||||
group('Concurrent Requests', () => {
|
||||
// Simulate multiple API calls in parallel
|
||||
const requests = [
|
||||
apiHelper.get('/pages', { limit: 5, depth: 1 }),
|
||||
apiHelper.get('/posts', { limit: 5, depth: 1 }),
|
||||
apiHelper.get('/portfolio', { limit: 5, depth: 1 }),
|
||||
];
|
||||
|
||||
// Check all succeeded
|
||||
const allSuccessful = requests.every(r => r.status === 200);
|
||||
|
||||
check(null, {
|
||||
'all concurrent requests successful': () => allSuccessful,
|
||||
});
|
||||
|
||||
thinkTime(0.2, 0.5);
|
||||
});
|
||||
|
||||
// Scenario 9: Filtered queries
|
||||
group('Filtered Queries', () => {
|
||||
// Various filter combinations
|
||||
const filters = [
|
||||
{ where: { status: { equals: 'published' } } },
|
||||
{ limit: 5, sort: '-createdAt' },
|
||||
{ limit: 10, depth: 2 },
|
||||
];
|
||||
|
||||
filters.forEach((filter, i) => {
|
||||
apiHelper.get('/posts', filter);
|
||||
thinkTime(0.05, 0.15);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal think time for API-focused test
|
||||
thinkTime(0.5, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== API Performance Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 300ms');
|
||||
console.log('- Error rate < 0.5%');
|
||||
console.log('- Throughput > 100 req/s');
|
||||
}
|
||||
195
apps/backend/tests/k6/lib/config.js
Normal file
195
apps/backend/tests/k6/lib/config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* K6 Test Configuration
|
||||
* Centralized configuration for all load tests
|
||||
*/
|
||||
|
||||
// Get environment variables with defaults
|
||||
export const config = {
|
||||
// Base URL for the target server
|
||||
get baseUrl() {
|
||||
return __ENV.BASE_URL || 'http://localhost:3000';
|
||||
},
|
||||
|
||||
// Admin credentials (from environment)
|
||||
get adminEmail() {
|
||||
return __ENV.ADMIN_EMAIL || 'admin@enchun.tw';
|
||||
},
|
||||
|
||||
get adminPassword() {
|
||||
return __ENV.ADMIN_PASSWORD || 'admin123';
|
||||
},
|
||||
|
||||
// Load testing thresholds (NFR4 requirements)
|
||||
thresholds: {
|
||||
// Response time thresholds
|
||||
httpReqDuration: ['p(95) < 500'], // 95th percentile < 500ms
|
||||
httpReqDurationApi: ['p(95) < 300'], // API endpoints < 300ms
|
||||
httpReqDurationAdmin: ['p(95) < 700'], // Admin operations < 700ms
|
||||
|
||||
// Error rate thresholds
|
||||
httpReqFailed: ['rate < 0.01'], // < 1% error rate
|
||||
httpReqFailedApi: ['rate < 0.005'], // API < 0.5% error rate
|
||||
|
||||
// Throughput thresholds
|
||||
httpReqs: ['rate > 50'], // > 50 requests/second for pages
|
||||
httpReqsApi: ['rate > 100'], // > 100 requests/second for API
|
||||
|
||||
// Check success rate
|
||||
checks: ['rate > 0.99'], // 99% of checks should pass
|
||||
},
|
||||
|
||||
// Stage configuration for gradual ramp-up
|
||||
stages: {
|
||||
// Public browsing: 100 users
|
||||
publicBrowsing: [
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '30s', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '1m', target: 100 }, // Ramp up to 100 users
|
||||
{ duration: '2m', target: 100 }, // Stay at 100 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
|
||||
// Admin operations: 20 users
|
||||
adminOperations: [
|
||||
{ duration: '30s', target: 5 }, // Ramp up to 5 users
|
||||
{ duration: '30s', target: 10 }, // Ramp up to 10 users
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '3m', target: 20 }, // Stay at 20 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
|
||||
// API performance: 50 users
|
||||
apiPerformance: [
|
||||
{ duration: '1m', target: 10 }, // Ramp up to 10 users
|
||||
{ duration: '1m', target: 25 }, // Ramp up to 25 users
|
||||
{ duration: '1m', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '5m', target: 50 }, // Stay at 50 users
|
||||
{ duration: '1m', target: 0 }, // Ramp down
|
||||
],
|
||||
},
|
||||
|
||||
// Common request options
|
||||
requestOptions: {
|
||||
timeout: '30s', // Request timeout
|
||||
maxRedirects: 10, // Maximum redirects to follow
|
||||
discardResponseBodies: true, // Discard response bodies to save memory
|
||||
},
|
||||
|
||||
// Common headers
|
||||
headers: {
|
||||
'User-Agent': 'k6-load-test',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9,zh-TW;q=0.8',
|
||||
},
|
||||
};
|
||||
|
||||
// URL helpers
|
||||
export const urls = {
|
||||
get baseUrl() {
|
||||
return config.baseUrl;
|
||||
},
|
||||
|
||||
// Public pages
|
||||
get home() {
|
||||
return `${config.baseUrl}/`;
|
||||
},
|
||||
get about() {
|
||||
return `${config.baseUrl}/about`;
|
||||
},
|
||||
get solutions() {
|
||||
return `${config.baseUrl}/solutions`;
|
||||
},
|
||||
get portfolio() {
|
||||
return `${config.baseUrl}/portfolio`;
|
||||
},
|
||||
get blog() {
|
||||
return `${config.baseUrl}/blog`;
|
||||
},
|
||||
get contact() {
|
||||
return `${config.baseUrl}/contact`;
|
||||
},
|
||||
|
||||
// API endpoints
|
||||
get api() {
|
||||
return `${config.baseUrl}/api`;
|
||||
},
|
||||
get graphql() {
|
||||
return `${config.baseUrl}/api/graphql`;
|
||||
},
|
||||
get global() {
|
||||
return `${config.baseUrl}/api/global`;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
get admin() {
|
||||
return `${config.baseUrl}/admin`;
|
||||
},
|
||||
get collections() {
|
||||
return `${config.baseUrl}/api/collections`;
|
||||
},
|
||||
get pages() {
|
||||
return `${config.baseUrl}/api/pages`;
|
||||
},
|
||||
get posts() {
|
||||
return `${config.baseUrl}/api/posts`;
|
||||
},
|
||||
get portfolioItems() {
|
||||
return `${config.baseUrl}/api/portfolio`;
|
||||
},
|
||||
get categories() {
|
||||
return `${config.baseUrl}/api/categories`;
|
||||
},
|
||||
|
||||
// Auth endpoints
|
||||
get login() {
|
||||
return `${config.baseUrl}/api/users/login`;
|
||||
},
|
||||
get logout() {
|
||||
return `${config.baseUrl}/api/users/logout`;
|
||||
},
|
||||
get me() {
|
||||
return `${config.baseUrl}/api/users/me`;
|
||||
},
|
||||
};
|
||||
|
||||
// Common checks
|
||||
export const checks = {
|
||||
// HTTP status checks
|
||||
status200: (res) => res.status === 200,
|
||||
status201: (res) => res.status === 201,
|
||||
status204: (res) => res.status === 204,
|
||||
|
||||
// Response time checks
|
||||
responseTimeFast: (res) => res.timings.duration < 200,
|
||||
responseTimeOk: (res) => res.timings.duration < 500,
|
||||
responseTimeSlow: (res) => res.timings.duration < 1000,
|
||||
|
||||
// Content checks
|
||||
hasContent: (res) => res.body.length > 0,
|
||||
hasJson: (res) => res.headers['Content-Type'].includes('application/json'),
|
||||
|
||||
// Performance checks (NFR4)
|
||||
nfr4ResponseTime: (res) => res.timings.duration < 500, // p95 < 500ms
|
||||
nfr4ApiResponseTime: (res) => res.timings.duration < 300, // API < 300ms
|
||||
};
|
||||
|
||||
// Threshold groups for different test types
|
||||
export const thresholdGroups = {
|
||||
public: {
|
||||
http_req_duration: ['p(95) < 500', 'p(99) < 1000'],
|
||||
http_req_failed: ['rate < 0.01'],
|
||||
checks: ['rate > 0.99'],
|
||||
},
|
||||
|
||||
admin: {
|
||||
http_req_duration: ['p(95) < 700', 'p(99) < 1500'],
|
||||
http_req_failed: ['rate < 0.01'],
|
||||
checks: ['rate > 0.99'],
|
||||
},
|
||||
|
||||
api: {
|
||||
http_req_duration: ['p(95) < 300', 'p(99) < 500'],
|
||||
http_req_failed: ['rate < 0.005'],
|
||||
checks: ['rate > 0.995'],
|
||||
},
|
||||
};
|
||||
405
apps/backend/tests/k6/lib/helpers.js
Normal file
405
apps/backend/tests/k6/lib/helpers.js
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* K6 Test Helpers
|
||||
* Reusable helper functions for load tests
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { urls, checks } from './config.js';
|
||||
|
||||
/**
|
||||
* Authentication helper
|
||||
*/
|
||||
export class AuthHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and store token
|
||||
*/
|
||||
login(email, password) {
|
||||
const res = http.post(`${this.baseUrl}/api/users/login`, JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const success = check(res, {
|
||||
'login successful': checks.status200,
|
||||
'received token': (r) => r.json('token') !== undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.token = res.json('token');
|
||||
}
|
||||
|
||||
return { res, success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers
|
||||
*/
|
||||
getAuthHeaders() {
|
||||
if (!this.token) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear token
|
||||
*/
|
||||
logout() {
|
||||
const res = http.post(`${this.baseUrl}/api/users/logout`, null, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
this.token = null;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page load helper
|
||||
*/
|
||||
export class PageHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page and check response
|
||||
*/
|
||||
loadPage(path) {
|
||||
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
||||
const res = http.get(url);
|
||||
|
||||
check(res, {
|
||||
[`page loaded: ${path}`]: checks.status200,
|
||||
'response time < 500ms': checks.nfr4ResponseTime,
|
||||
'has content': checks.hasContent,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple pages randomly
|
||||
*/
|
||||
loadRandomPages(pageList, count = 5) {
|
||||
const pages = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const randomPage = pageList[Math.floor(Math.random() * pageList.length)];
|
||||
pages.push(this.loadPage(randomPage));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API helper
|
||||
*/
|
||||
export class ApiHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth token
|
||||
*/
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers
|
||||
*/
|
||||
getHeaders(additionalHeaders = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request
|
||||
*/
|
||||
get(endpoint, params = {}) {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||
|
||||
const res = http.get(url.toString(), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`GET ${endpoint} successful`]: checks.status200,
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*/
|
||||
post(endpoint, data) {
|
||||
const res = http.post(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`POST ${endpoint} successful`]: (r) => [200, 201].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*/
|
||||
put(endpoint, data) {
|
||||
const res = http.put(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`PUT ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*/
|
||||
delete(endpoint) {
|
||||
const res = http.del(`${this.baseUrl}${endpoint}`, null, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`DELETE ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL query helper
|
||||
*/
|
||||
graphql(query, variables = {}) {
|
||||
const res = http.post(`${this.baseUrl}/api/graphql`, JSON.stringify({
|
||||
query,
|
||||
variables,
|
||||
}), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
'GraphQL successful': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
const body = r.json();
|
||||
return !body.errors;
|
||||
},
|
||||
'GraphQL response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Think time helper
|
||||
* Simulates real user think time between actions
|
||||
*/
|
||||
export function thinkTime(min = 1, max = 3) {
|
||||
sleep(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random item picker
|
||||
*/
|
||||
export function pickRandom(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted random picker
|
||||
*/
|
||||
export function pickWeighted(items) {
|
||||
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const item of items) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
return item.value;
|
||||
}
|
||||
}
|
||||
|
||||
return items[0].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random test data
|
||||
*/
|
||||
export const testData = {
|
||||
// Random string
|
||||
string(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// Random email
|
||||
email() {
|
||||
return `test_${this.string(8)}@example.com`;
|
||||
},
|
||||
|
||||
// Random number
|
||||
number(min = 0, max = 100) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
},
|
||||
|
||||
// Random phone
|
||||
phone() {
|
||||
return `09${this.number(10000000, 99999999)}`;
|
||||
},
|
||||
|
||||
// Random title
|
||||
title() {
|
||||
const adjectives = ['Amazing', 'Awesome', 'Brilliant', 'Creative', 'Dynamic'];
|
||||
const nouns = ['Project', 'Solution', 'Product', 'Service', 'Innovation'];
|
||||
return `${pickRandom(adjectives)} ${pickRandom(nouns)}`;
|
||||
},
|
||||
|
||||
// Random content
|
||||
content(paragraphs = 3) {
|
||||
const sentences = [
|
||||
'This is a test sentence for load testing purposes.',
|
||||
'Load testing helps identify performance bottlenecks.',
|
||||
'We ensure the system can handle expected traffic.',
|
||||
'Performance is critical for user experience.',
|
||||
'Testing under load reveals system behavior.',
|
||||
];
|
||||
|
||||
let content = '';
|
||||
for (let i = 0; i < paragraphs; i++) {
|
||||
content += '\n\n';
|
||||
for (let j = 0; j < 3; j++) {
|
||||
content += pickRandom(sentences) + ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Metrics helper
|
||||
*/
|
||||
export class MetricsHelper {
|
||||
constructor() {
|
||||
this.metrics = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric
|
||||
*/
|
||||
record(name, value) {
|
||||
if (!this.metrics[name]) {
|
||||
this.metrics[name] = {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Infinity,
|
||||
max: -Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const metric = this.metrics[name];
|
||||
metric.count++;
|
||||
metric.sum += value;
|
||||
metric.min = Math.min(metric.min, value);
|
||||
metric.max = Math.max(metric.max, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric statistics
|
||||
*/
|
||||
getStats(name) {
|
||||
const metric = this.metrics[name];
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
count: metric.count,
|
||||
sum: metric.sum,
|
||||
avg: metric.sum / metric.count,
|
||||
min: metric.min,
|
||||
max: metric.max,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print all metrics
|
||||
*/
|
||||
printAll() {
|
||||
console.log('=== Custom Metrics ===');
|
||||
Object.keys(this.metrics).forEach(name => {
|
||||
const stats = this.getStats(name);
|
||||
console.log(`${name}:`, stats);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario helper
|
||||
* Define test scenarios with weights
|
||||
*/
|
||||
export class ScenarioHelper {
|
||||
constructor() {
|
||||
this.scenarios = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scenario
|
||||
*/
|
||||
register(name, fn, weight = 1) {
|
||||
this.scenarios[name] = { fn, weight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a random scenario based on weights
|
||||
*/
|
||||
execute() {
|
||||
const scenarios = Object.entries(this.scenarios).map(([name, { fn, weight }]) => ({
|
||||
value: fn,
|
||||
weight,
|
||||
}));
|
||||
|
||||
const scenario = pickWeighted(scenarios);
|
||||
scenario();
|
||||
}
|
||||
}
|
||||
119
apps/backend/tests/k6/public-browsing.js
Normal file
119
apps/backend/tests/k6/public-browsing.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Public Browsing Load Test
|
||||
*
|
||||
* Simulates 100 concurrent users browsing public pages
|
||||
* Tests:
|
||||
* - Homepage
|
||||
* - About page
|
||||
* - Solutions page
|
||||
* - Portfolio list
|
||||
* - Blog list
|
||||
* - Contact page
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 500ms
|
||||
* - Error rate < 1%
|
||||
* - 100 concurrent users sustained for 2 minutes
|
||||
*/
|
||||
|
||||
import { check, group } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { PageHelper, thinkTime, pickRandom } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.publicBrowsing,
|
||||
thresholds: thresholdGroups.public,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// Page list to browse
|
||||
const publicPages = [
|
||||
urls.home,
|
||||
urls.about,
|
||||
urls.solutions,
|
||||
urls.portfolio,
|
||||
urls.blog,
|
||||
urls.contact,
|
||||
];
|
||||
|
||||
// Initialize page helper
|
||||
const pageHelper = new PageHelper(config.baseUrl);
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Scenario 1: Browse homepage (most common)
|
||||
group('Browse Homepage', () => {
|
||||
pageHelper.loadPage(urls.home);
|
||||
thinkTime(2, 4); // 2-4 seconds thinking
|
||||
});
|
||||
|
||||
// Scenario 2: Browse random pages (weighted)
|
||||
group('Browse Random Pages', () => {
|
||||
// Browse 3-6 random pages
|
||||
const pageCount = Math.floor(Math.random() * 4) + 3;
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const randomPage = pickRandom(publicPages);
|
||||
pageHelper.loadPage(randomPage);
|
||||
thinkTime(1, 3); // 1-3 seconds thinking
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 3: Navigate to contact (conversion intent)
|
||||
group('Navigate to Contact', () => {
|
||||
// 20% chance to visit contact page
|
||||
if (Math.random() < 0.2) {
|
||||
pageHelper.loadPage(urls.contact);
|
||||
thinkTime(3, 5); // More time on contact page
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 4: Deep dive into portfolio or blog
|
||||
group('Deep Dive', () => {
|
||||
// 30% chance to deep dive
|
||||
if (Math.random() < 0.3) {
|
||||
const section = Math.random() > 0.5 ? 'portfolio' : 'blog';
|
||||
|
||||
if (section === 'portfolio') {
|
||||
// Browse portfolio items
|
||||
pageHelper.loadPage(urls.portfolio);
|
||||
thinkTime(1, 2);
|
||||
// Note: In real scenario, we would click individual items
|
||||
// This requires parsing the page to get item URLs
|
||||
} else {
|
||||
// Browse blog posts
|
||||
pageHelper.loadPage(urls.blog);
|
||||
thinkTime(1, 2);
|
||||
// Note: In real scenario, we would click individual posts
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Small think time before next iteration
|
||||
thinkTime(2, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup function - runs once before test
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== Public Browsing Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log(`Pages to browse: ${publicPages.length}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function - runs once after test
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 500ms');
|
||||
console.log('- Error rate < 1%');
|
||||
console.log('- 100 concurrent users sustained');
|
||||
}
|
||||
59
apps/backend/tests/k6/verify-setup.js
Normal file
59
apps/backend/tests/k6/verify-setup.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* K6 Setup Verification Script
|
||||
* Run this to verify your k6 installation and environment
|
||||
*/
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
|
||||
// Test configuration - minimal load
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95) < 1000'], // Relaxed for verification
|
||||
http_req_failed: ['rate < 0.05'], // Allow some failures during setup
|
||||
},
|
||||
};
|
||||
|
||||
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
export default function () {
|
||||
console.log(`=== K6 Setup Verification ===`);
|
||||
console.log(`Target: ${baseUrl}`);
|
||||
|
||||
// Test 1: Server is reachable
|
||||
const homeRes = http.get(baseUrl);
|
||||
check(homeRes, {
|
||||
'Server is reachable': (r) => r.status !== 0,
|
||||
'Home page responds': (r) => [200, 301, 302, 404].includes(r.status),
|
||||
});
|
||||
console.log(`Home page status: ${homeRes.status}`);
|
||||
|
||||
// Test 2: API endpoint exists
|
||||
const apiRes = http.get(`${baseUrl}/api/global`);
|
||||
check(apiRes, {
|
||||
'API endpoint responds': (r) => r.status !== 0,
|
||||
});
|
||||
console.log(`API status: ${apiRes.status}`);
|
||||
|
||||
// Test 3: Check response times
|
||||
const timeRes = http.get(`${baseUrl}/api/pages`, {
|
||||
tags: { name: 'pages_api' },
|
||||
});
|
||||
console.log(`Pages API response time: ${timeRes.timings.duration}ms`);
|
||||
|
||||
// Summary
|
||||
console.log(`=== Verification Complete ===`);
|
||||
console.log(`If all checks passed, you're ready to run load tests!`);
|
||||
console.log(`Next: k6 run tests/k6/public-browsing.js`);
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
console.log(`Starting K6 verification...`);
|
||||
console.log(`k6 version: ${__K6_VERSION__ || 'unknown'}`);
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
console.log(`Verification finished.`);
|
||||
}
|
||||
1733
apps/frontend/pnpm-lock.yaml
generated
1733
apps/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user