Compare commits

...

4 Commits

Author SHA1 Message Date
7fd73e0e3d 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
2026-01-31 17:20:35 +08:00
0846318d6e Complete Story 1-1 and fix TypeScript issues
Add TypeScript strict mode and typecheck tasks to monorepo infrastructure.
Fix E2E test @payload-config alias and frontend TypeScript errors.

- Add tsconfig.json to backend with strict mode and path aliases
- Add typecheck task to Turborepo and all packages
- Fix @payload-config alias for E2E tests and dev server
- Add setToken method to AuthService for middleware use
- Fix implicit any types in Footer.astro and Header.astro
- Remove invalid typescript config from astro.config.mjs
2026-01-31 17:12:47 +08:00
d0e8c3bcff Add role-based access control with admin/editor roles
Create adminOnly and adminOrEditor access functions. Add role field
to Users collection (admin/editor, default: editor). Update access control
across all collections and globals to enforce role-based permissions.
2026-01-31 13:03:16 +08:00
2d3d144a66 Add Portfolio collection with 7 fields
Create Portfolio collection for managing website portfolio items.
Includes title, slug, url, image, description, websiteType, and
tags fields. Configured access control for authenticated create/
update/delete operations with public read access.
2026-01-31 12:54:36 +08:00
66 changed files with 19956 additions and 5715 deletions

View File

@@ -20,17 +20,20 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test --config=playwright.config.ts",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
"test:load": "k6 run tests/k6/public-browsing.js",
"test:load:all": "k6 run tests/k6/public-browsing.js && k6 run tests/k6/api-performance.js",
"test:load:admin": "k6 run tests/k6/admin-operations.js",
"test:load:api": "k6 run tests/k6/api-performance.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opennextjs/cloudflare": "^1.10.1",
"@enchun/shared": "workspace:*",
"@payloadcms/admin-bar": "3.59.1",
"@payloadcms/db-mongodb": "3.59.1",
"@payloadcms/email-resend": "3.59.1",
"@payloadcms/live-preview-react": "3.59.1",
"@payloadcms/next": "3.59.1",
"@payloadcms/payload-cloud": "3.59.1",
"@payloadcms/plugin-form-builder": "3.59.1",
"@payloadcms/plugin-nested-docs": "3.59.1",
"@payloadcms/plugin-redirects": "3.59.1",
"@payloadcms/plugin-search": "3.59.1",
@@ -58,8 +61,7 @@
"react-hook-form": "7.45.4",
"sharp": "0.34.2",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"@enchun/shared": "workspace:*"
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +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'
@@ -7,6 +9,7 @@ export const Footer: GlobalConfig = {
slug: 'footer',
access: {
read: () => true,
update: adminOnly,
},
fields: [
{
@@ -41,6 +44,6 @@ export const Footer: GlobalConfig = {
},
],
hooks: {
afterChange: [revalidateFooter],
afterChange: [revalidateFooter, auditGlobalChange('footer')],
},
}

View File

@@ -1,13 +1,27 @@
import type { GlobalAfterChangeHook } from 'payload'
import { revalidateTag } from 'next/cache'
import { auditLogger } from '@/utilities/auditLogger'
export const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
export const revalidateFooter: GlobalAfterChangeHook = async ({ doc, req }) => {
const { payload, context } = req
if (!context.disableRevalidate) {
payload.logger.info(`Revalidating footer`)
revalidateTag('global_footer')
}
// 記錄 Footer 變更
if (req.user) {
await auditLogger(req, {
action: 'update',
collection: 'global_footer',
userId: req.user.id,
userName: req.user.name as string,
userEmail: req.user.email as string,
userRole: req.user.role as string,
})
}
return doc
}

View File

@@ -1,5 +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'
@@ -7,6 +9,7 @@ export const Header: GlobalConfig = {
slug: 'header',
access: {
read: () => true,
update: adminOnly,
},
fields: [
{
@@ -27,6 +30,6 @@ export const Header: GlobalConfig = {
},
],
hooks: {
afterChange: [revalidateHeader],
afterChange: [revalidateHeader, auditGlobalChange('header')],
},
}

View File

@@ -1,13 +1,27 @@
import type { GlobalAfterChangeHook } from 'payload'
import { revalidateTag } from 'next/cache'
import { auditLogger } from '@/utilities/auditLogger'
export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
export const revalidateHeader: GlobalAfterChangeHook = async ({ doc, req }) => {
const { payload, context } = req
if (!context.disableRevalidate) {
payload.logger.info(`Revalidating header`)
revalidateTag('global_header')
}
// 記錄 Header 變更
if (req.user) {
await auditLogger(req, {
action: 'update',
collection: 'global_header',
userId: req.user.id,
userName: req.user.name as string,
userEmail: req.user.email as string,
userRole: req.user.role as string,
})
}
return doc
}

View File

@@ -0,0 +1,13 @@
import type { Access } from 'payload'
/**
* 僅允許 Admin 角色訪問
*
* 用例:
* - Users collection (敏感操作)
* - Globals (Header/Footer)
* - System settings
*/
export const adminOnly: Access = ({ req: { user } }) => {
return user?.role === 'admin'
}

View File

@@ -0,0 +1,14 @@
import type { Access } from 'payload'
/**
* 允許 Admin 或 Editor 角色訪問
*
* 用例:
* - Posts/Pages collection (內容管理)
* - Categories collection (內容分類)
* - Portfolio collection (作品管理)
*/
export const adminOrEditor: Access = ({ req: { user } }) => {
if (!user) return false
return user?.role === 'admin' || user?.role === 'editor'
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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',
},
]

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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' },
]

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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',
},
}

View File

@@ -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,
}

View File

@@ -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,
}

View 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
}

View 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,
}

View File

@@ -2,24 +2,67 @@ 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 = {
slug: 'categories',
access: {
create: authenticated,
delete: authenticated,
create: adminOrEditor,
delete: adminOrEditor,
read: anyone,
update: authenticated,
update: adminOrEditor,
},
admin: {
useAsTitle: 'title',
},
hooks: {
afterChange: [auditChange('categories')],
afterDelete: [auditChange('categories')],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
label: '分類名稱(中文)',
},
{
name: 'nameEn',
type: 'text',
label: '英文名稱',
admin: {
description: '用於 URL 或國際化',
},
},
{
name: 'order',
type: 'number',
label: '排序順序',
defaultValue: 0,
admin: {
position: 'sidebar',
description: '數字越小越靠前',
},
},
{
name: 'textColor',
type: 'text',
label: '文字顏色',
defaultValue: '#000000',
admin: {
description: '十六進制顏色碼,例如 #000000',
},
},
{
name: 'backgroundColor',
type: 'text',
label: '背景顏色',
defaultValue: '#ffffff',
admin: {
description: '十六進制顏色碼,例如 #ffffff',
},
},
...slugField(),
],

View File

@@ -2,10 +2,11 @@ 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'
import { FormBlock } from '../../blocks/Form/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { hero } from '@/heros/config'
import { slugField } from '@/fields/slug'
@@ -24,10 +25,10 @@ import {
export const Pages: CollectionConfig<'pages'> = {
slug: 'pages',
access: {
create: authenticated,
delete: authenticated,
create: adminOrEditor,
delete: adminOrEditor,
read: authenticatedOrPublished,
update: authenticated,
update: adminOrEditor,
},
// This config controls what's populated by default when a page is referenced
// https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property
@@ -75,7 +76,7 @@ export const Pages: CollectionConfig<'pages'> = {
{
name: 'layout',
type: 'blocks',
blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock],
blocks: [CallToAction, Content, MediaBlock, Archive],
required: true,
admin: {
initCollapsed: true,
@@ -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: {

View File

@@ -0,0 +1,60 @@
import { Portfolio } from '../index'
describe('Portfolio Collection', () => {
it('should have correct slug', () => {
expect(Portfolio.slug).toBe('portfolio')
})
it('should have all required fields', () => {
const fieldNames = Portfolio.fields.map((f) => ('name' in f ? f.name : null))
expect(fieldNames).toContain('title')
expect(fieldNames).toContain('slug')
expect(fieldNames).toContain('url')
expect(fieldNames).toContain('image')
expect(fieldNames).toContain('description')
expect(fieldNames).toContain('websiteType')
expect(fieldNames).toContain('tags')
})
it('should have correct access control', () => {
expect(Portfolio.access.read).toBeDefined()
expect(Portfolio.access.create).toBeDefined()
expect(Portfolio.access.update).toBeDefined()
expect(Portfolio.access.delete).toBeDefined()
})
it('should have admin configuration', () => {
expect(Portfolio.admin).toBeDefined()
expect(Portfolio.admin?.useAsTitle).toBe('title')
expect(Portfolio.admin?.defaultColumns).toEqual(['title', 'websiteType', 'updatedAt'])
})
it('should have websiteType field with correct options', () => {
const websiteTypeField = Portfolio.fields.find((f) => f.name === 'websiteType')
expect(websiteTypeField).toBeDefined()
expect(websiteTypeField?.type).toBe('select')
if (websiteTypeField?.type === 'select') {
const optionValues = websiteTypeField.options.map((o) => o.value)
expect(optionValues).toContain('corporate')
expect(optionValues).toContain('ecommerce')
expect(optionValues).toContain('landing')
expect(optionValues).toContain('brand')
expect(optionValues).toContain('other')
}
})
it('should have tags field as array', () => {
const tagsField = Portfolio.fields.find((f) => f.name === 'tags')
expect(tagsField).toBeDefined()
expect(tagsField?.type).toBe('array')
})
it('should have image field with relation to media', () => {
const imageField = Portfolio.fields.find((f) => f.name === 'image')
expect(imageField).toBeDefined()
expect(imageField?.type).toBe('upload')
if (imageField?.type === 'upload') {
expect(imageField.relationTo).toBe('media')
}
})
})

View File

@@ -0,0 +1,80 @@
import type { CollectionConfig } from 'payload'
import { anyone } from '../../access/anyone'
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: authenticated,
read: anyone,
update: authenticated,
delete: authenticated,
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'websiteType', 'updatedAt'],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'url',
type: 'text',
admin: {
description: 'Website URL (e.g., https://example.com)',
},
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: true,
admin: {
description: 'Preview image stored in R2',
},
},
{
name: 'description',
type: 'textarea',
},
{
name: 'websiteType',
type: 'select',
options: [
{ label: '企業官網', value: 'corporate' },
{ label: '電商網站', value: 'ecommerce' },
{ label: '活動頁面', value: 'landing' },
{ label: '品牌網站', value: 'brand' },
{ label: '其他', value: 'other' },
],
required: true,
},
{
name: 'tags',
type: 'array',
fields: [
{
name: 'tag',
type: 'text',
},
],
},
...slugField(),
],
hooks: {
afterChange: [auditChange('portfolio')],
afterDelete: [auditChange('portfolio')],
},
versions: {
drafts: {
autosave: true,
},
maxPerDoc: 10,
},
}

View File

@@ -11,6 +11,8 @@ 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'
@@ -30,10 +32,10 @@ import { slugField } from '@/fields/slug'
export const Posts: CollectionConfig<'posts'> = {
slug: 'posts',
access: {
create: authenticated,
delete: authenticated,
create: adminOrEditor,
delete: adminOrEditor,
read: authenticatedOrPublished,
update: authenticated,
update: adminOrEditor,
},
// This config controls what's populated by default when a post is referenced
// https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property
@@ -84,6 +86,15 @@ export const Posts: CollectionConfig<'posts'> = {
type: 'upload',
relationTo: 'media',
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: '社群分享圖片',
admin: {
description: 'Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px',
},
},
{
name: 'content',
type: 'richText',
@@ -102,6 +113,16 @@ export const Posts: CollectionConfig<'posts'> = {
label: false,
required: true,
},
{
name: 'excerpt',
type: 'text',
label: '文章摘要',
admin: {
description: '顯示在文章列表頁,建議 150-200 字',
multiline: true,
},
maxLength: 200,
},
],
label: 'Content',
},
@@ -132,6 +153,15 @@ export const Posts: CollectionConfig<'posts'> = {
hasMany: true,
relationTo: 'categories',
},
{
name: 'showInFooter',
type: 'checkbox',
label: '顯示在頁腳',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
],
label: 'Meta',
},
@@ -218,11 +248,25 @@ export const Posts: CollectionConfig<'posts'> = {
],
},
...slugField(),
{
name: 'status',
type: 'select',
label: '文章狀態',
defaultValue: 'draft',
options: [
{ label: '草稿', value: 'draft' },
{ label: '審核中', value: 'review' },
{ label: '已發布', value: 'published' },
],
admin: {
position: 'sidebar',
},
},
],
hooks: {
afterChange: [revalidatePost],
afterChange: [revalidatePost, auditChange('posts')],
afterRead: [populateAuthors],
afterDelete: [revalidateDelete],
afterDelete: [revalidateDelete, auditChange('posts')],
},
versions: {
drafts: {

View File

@@ -1,26 +1,60 @@
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',
access: {
admin: authenticated,
create: authenticated,
delete: authenticated,
admin: adminOnly,
create: adminOnly,
delete: adminOnly,
read: authenticated,
update: authenticated,
update: adminOnly,
},
admin: {
defaultColumns: ['name', 'email'],
defaultColumns: ['name', 'email', 'role'],
useAsTitle: 'name',
},
auth: true,
hooks: {
afterLogin: [afterLogin],
afterLogout: [afterLogout],
},
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'role',
type: 'select',
label: '角色',
defaultValue: 'editor',
required: true,
options: [
{ label: '管理員', value: 'admin' },
{ label: '編輯者', value: 'editor' },
],
admin: {
position: 'sidebar',
},
},
],
timestamps: true,
}

View File

@@ -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',
}

View File

@@ -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([

View 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',
}
}
},
}

View File

@@ -72,9 +72,9 @@ export interface Config {
media: Media;
categories: Category;
users: User;
portfolio: Portfolio;
audit: Audit;
redirects: Redirect;
forms: Form;
'form-submissions': FormSubmission;
search: Search;
'payload-jobs': PayloadJob;
'payload-locked-documents': PayloadLockedDocument;
@@ -88,9 +88,9 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>;
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>;
forms: FormsSelect<false> | FormsSelect<true>;
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
search: SearchSelect<false> | SearchSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -114,6 +114,7 @@ export interface Config {
};
jobs: {
tasks: {
'cleanup-audit-logs': TaskCleanupAuditLogs;
schedulePublish: TaskSchedulePublish;
inline: {
input: unknown;
@@ -154,7 +155,7 @@ export interface Page {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -191,7 +192,7 @@ export interface Page {
| null;
media?: (string | null) | Media;
};
layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock | FormBlock)[];
layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock)[];
meta?: {
title?: string | null;
/**
@@ -215,11 +216,15 @@ export interface Post {
id: string;
title: string;
heroImage?: (string | null) | Media;
/**
* Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px
*/
ogImage?: (string | null) | Media;
content: {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -230,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;
/**
@@ -250,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;
@@ -265,7 +276,7 @@ export interface Media {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -353,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;
@@ -374,6 +401,7 @@ export interface Category {
export interface User {
id: string;
name?: string | null;
role: 'admin' | 'editor';
updatedAt: string;
createdAt: string;
email: string;
@@ -401,7 +429,7 @@ export interface CallToActionBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -452,7 +480,7 @@ export interface ContentBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -509,7 +537,7 @@ export interface ArchiveBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -536,201 +564,87 @@ export interface ArchiveBlock {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FormBlock".
* via the `definition` "portfolio".
*/
export interface FormBlock {
form: string | Form;
enableIntro?: boolean | null;
introContent?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'formBlock';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "forms".
*/
export interface Form {
export interface Portfolio {
id: string;
title: string;
fields?:
| (
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
defaultValue?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'checkbox';
}
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'country';
}
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'email';
}
| {
message?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'message';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'number';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: string | null;
placeholder?: string | null;
options?:
| {
label: string;
value: string;
id?: string | null;
}[]
| null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'select';
}
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'state';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: string | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'text';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: string | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'textarea';
}
)[]
| null;
submitButtonLabel?: string | null;
/**
* Choose whether to display an on-page message or redirect to a different page after they submit the form.
* Website URL (e.g., https://example.com)
*/
confirmationType?: ('message' | 'redirect') | null;
confirmationMessage?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
redirect?: {
url: string;
};
url?: string | null;
/**
* Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email.
* Preview image stored in R2
*/
emails?:
image: string | Media;
description?: string | null;
websiteType: 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other';
tags?:
| {
emailTo?: string | null;
cc?: string | null;
bcc?: string | null;
replyTo?: string | null;
emailFrom?: string | null;
subject: string;
/**
* Enter the message that should be sent in this email.
*/
message?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
tag?: string | null;
id?: string | null;
}[]
| null;
slug?: string | null;
slugLock?: boolean | null;
updatedAt: string;
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;
}
@@ -760,23 +674,6 @@ export interface Redirect {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions".
*/
export interface FormSubmission {
id: string;
form: string | Form;
submissionData?:
| {
field: string;
value: string;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This is a collection of automatically created search results. These results are used by the global site search and will be updated automatically as documents in the CMS are created or updated.
*
@@ -860,7 +757,7 @@ export interface PayloadJob {
| {
executedAt: string;
completedAt: string;
taskSlug: 'inline' | 'schedulePublish';
taskSlug: 'inline' | 'cleanup-audit-logs' | 'schedulePublish';
taskID: string;
input?:
| {
@@ -893,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;
@@ -927,18 +824,18 @@ export interface PayloadLockedDocument {
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'portfolio';
value: string | Portfolio;
} | null)
| ({
relationTo: 'audit';
value: string | Audit;
} | null)
| ({
relationTo: 'redirects';
value: string | Redirect;
} | null)
| ({
relationTo: 'forms';
value: string | Form;
} | null)
| ({
relationTo: 'form-submissions';
value: string | FormSubmission;
} | null)
| ({
relationTo: 'search';
value: string | Search;
@@ -1024,7 +921,6 @@ export interface PagesSelect<T extends boolean = true> {
content?: T | ContentBlockSelect<T>;
mediaBlock?: T | MediaBlockSelect<T>;
archive?: T | ArchiveBlockSelect<T>;
formBlock?: T | FormBlockSelect<T>;
};
meta?:
| T
@@ -1113,17 +1009,6 @@ export interface ArchiveBlockSelect<T extends boolean = true> {
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FormBlock_select".
*/
export interface FormBlockSelect<T extends boolean = true> {
form?: T;
enableIntro?: T;
introContent?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
@@ -1131,9 +1016,12 @@ export interface FormBlockSelect<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
| {
@@ -1151,6 +1039,7 @@ export interface PostsSelect<T extends boolean = true> {
};
slug?: T;
slugLock?: T;
status?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -1254,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;
@@ -1274,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;
@@ -1291,6 +1185,47 @@ export interface UsersSelect<T extends boolean = true> {
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "portfolio_select".
*/
export interface PortfolioSelect<T extends boolean = true> {
title?: T;
url?: T;
image?: T;
description?: T;
websiteType?: T;
tags?:
| T
| {
tag?: T;
id?: T;
};
slug?: T;
slugLock?: T;
updatedAt?: T;
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".
@@ -1307,155 +1242,6 @@ export interface RedirectsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "forms_select".
*/
export interface FormsSelect<T extends boolean = true> {
title?: T;
fields?:
| T
| {
checkbox?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
defaultValue?: T;
id?: T;
blockName?: T;
};
country?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
id?: T;
blockName?: T;
};
email?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
id?: T;
blockName?: T;
};
message?:
| T
| {
message?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
required?: T;
id?: T;
blockName?: T;
};
select?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
placeholder?: T;
options?:
| T
| {
label?: T;
value?: T;
id?: T;
};
required?: T;
id?: T;
blockName?: T;
};
state?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
id?: T;
blockName?: T;
};
text?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
required?: T;
id?: T;
blockName?: T;
};
textarea?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
required?: T;
id?: T;
blockName?: T;
};
};
submitButtonLabel?: T;
confirmationType?: T;
confirmationMessage?: T;
redirect?:
| T
| {
url?: T;
};
emails?:
| T
| {
emailTo?: T;
cc?: T;
bcc?: T;
replyTo?: T;
emailFrom?: T;
subject?: T;
message?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions_select".
*/
export interface FormSubmissionsSelect<T extends boolean = true> {
form?: T;
submissionData?:
| T
| {
field?: T;
value?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "search_select".
@@ -1684,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".
@@ -1716,7 +1510,7 @@ export interface BannerBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];

View File

@@ -6,9 +6,11 @@ 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'
import { Portfolio } from './collections/Portfolio'
import { Posts } from './collections/Posts'
import { Users } from './collections/Users'
import { Footer } from './Footer/config'
@@ -16,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)
@@ -62,7 +65,7 @@ export default buildConfig({
db: mongooseAdapter({
url: process.env.DATABASE_URI || '',
}),
collections: [Pages, Posts, Media, Categories, Users],
collections: [Pages, Posts, Media, Categories, Users, Portfolio, Audit],
cors: [
getServerSideURL(),
'http://localhost:4321', // Astro dev server
@@ -110,6 +113,6 @@ export default buildConfig({
return authHeader === `Bearer ${process.env.CRON_SECRET}`
},
},
tasks: [],
tasks: [cleanupAuditLogs],
},
})

View File

@@ -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(),
]

View File

@@ -0,0 +1,11 @@
/**
* 稽核日誌操作類型
*/
export type AuditAction =
| 'login'
| 'logout'
| 'create'
| 'update'
| 'delete'
| 'publish'
| 'unpublish'

View 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,
})
}

View 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

View 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...

View 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!** 🚀

View 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

View 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

View 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');
}

View 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');
}

View 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'],
},
};

View 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();
}
}

View 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');
}

View 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.`);
}

View File

@@ -1,51 +1,55 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"esModuleInterop": true,
"target": "ES2022",
"module": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"incremental": true,
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"sourceMap": true,
"isolatedModules": true,
"allowJs": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config"
]
},
"plugins": [
{
"name": "next"
}
],
"paths": {
"@payload-config": [
"./src/payload.config.ts"
],
"react": [
"./node_modules/@types/react"
],
"@/*": [
"./src/*"
],
}
"noEmit": true,
"incremental": true,
"isolatedModules": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"redirects.js",
"next-env.d.ts",
"src/**/*",
"next.config.js",
"next-sitemap.config.cjs"
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
],
"node_modules",
".next",
"dist",
"tests/**/*",
"**/__tests__/**/*",
"**/*.spec.ts",
"**/*.test.ts",
"vitest.config.mts",
"playwright.config.ts"
]
}

View File

@@ -1,9 +1,15 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import path from 'node:path'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],

View File

@@ -6,7 +6,13 @@ import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config
export default defineConfig({
output: "server",
adapter: cloudflare(),
adapter: cloudflare({
imageService: 'passthrough',
platformProxy: {
enabled: true,
configPath: './wrangler.jsonc',
},
}),
vite: {
plugins: [tailwindcss()],
server: {
@@ -19,7 +25,4 @@ export default defineConfig({
},
},
},
typescript: {
strict: true,
},
});

View File

@@ -9,18 +9,21 @@
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"deploy": "wrangler pages deploy dist"
"typecheck": "astro check",
"deploy": "wrangler deploy"
},
"dependencies": {
"@astrojs/cloudflare": "^12.6.9",
"@astrojs/cloudflare": "^12.6.12",
"@tailwindcss/vite": "^4.1.14",
"astro": "^5.14.1",
"astro": "6.0.0-beta.1",
"better-auth": "^1.3.13"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.0",
"tailwindcss": "^4.1.14",
"typescript": "^5.4.0"
"typescript": "^5.7.3",
"wrangler": "^4.59.2"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -51,6 +51,13 @@ import { Image } from 'astro:assets';
<script>
// Client-side data fetching for footer
interface LinkItem {
link?: {
url?: string;
label?: string;
};
}
async function loadFooterData() {
try {
console.log('Fetching footer data...');
@@ -61,7 +68,7 @@ import { Image } from 'astro:assets';
// Update marketing solutions
const marketingUl = document.getElementById('marketing-solutions');
if (marketingUl && data.navItems?.[0]?.childNavItems) {
const links = data.navItems[0].childNavItems.map(item =>
const links = data.navItems[0].childNavItems.map((item: LinkItem) =>
`<li><a href="${item.link?.url || '#'}" class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">${item.link?.label}</a></li>`
).join('');
marketingUl.innerHTML = links;
@@ -70,7 +77,7 @@ import { Image } from 'astro:assets';
// Update marketing articles (行銷放大鏡)
const articlesUl = document.getElementById('marketing-articles');
if (articlesUl && data.navItems?.[1]?.childNavItems) {
const links = data.navItems[1].childNavItems.map(item =>
const links = data.navItems[1].childNavItems.map((item: LinkItem) =>
`<li><a href="${item.link?.url || '#'}" class="font-normal text-[var(--color-st-tropaz)] hover:text-[var(--color-dove-gray)] transition-colors">${item.link?.label}</a></li>`
).join('');
articlesUl.innerHTML = links;

View File

@@ -135,7 +135,7 @@ import { Image } from "astro:assets";
mobileNav.innerHTML = "";
// Populate desktop navigation
navItems.forEach((item) => {
navItems.forEach((item: NavItem) => {
const linkHtml = createNavLink(item);
const li = document.createElement("li");
li.innerHTML = linkHtml;
@@ -143,7 +143,7 @@ import { Image } from "astro:assets";
});
// Populate mobile navigation
navItems.forEach((item) => {
navItems.forEach((item: NavItem) => {
const linkHtml = createNavLink(item)
.replace("px-3 py-2", "block px-3 py-2")
.replace(

View File

@@ -15,7 +15,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
// Validate token
authService.token = token;
authService.setToken(token);
const user = await authService.getCurrentUser();
if (!user) {

View File

@@ -61,6 +61,7 @@ export class AuthService {
}
async logout(): Promise<void> {
const tokenForRequest = this.token;
this.token = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('payload-token');
@@ -68,17 +69,25 @@ export class AuthService {
// Optional: Call logout endpoint
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (tokenForRequest) {
headers['Authorization'] = `Bearer ${tokenForRequest}`;
}
await fetch(`${PAYLOAD_URL}/api/users/logout`, {
method: 'POST',
headers: {
...(this.token && { 'Authorization': `Bearer ${this.token}` }),
},
headers,
});
} catch (error) {
// Ignore logout errors
}
}
setToken(token: string): void {
this.token = token;
}
async getCurrentUser(): Promise<User | null> {
if (!this.token) return null;

View File

@@ -5,5 +5,6 @@
"paths": {
"@shared/*": ["../packages/shared/src/*"]
}
}
},
"exclude": ["node_modules", "dist", "tests"]
}

View File

@@ -8,11 +8,13 @@
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"bmad:refresh": "bmad-method install -f -i codex",
"bmad:list": "bmad-method list:agents",
"bmad:validate": "bmad-method validate"
},
"devDependencies": {
"eslint-plugin-react-hooks": "^7.0.1",
"turbo": "^2.0.5"
},
"pnpm": {

View File

@@ -5,10 +5,17 @@
"declarationDir": "dist",
"emitDeclarationOnly": true,
"module": "ES2020",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"target": "ES2020",
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}

15411
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,10 @@
},
"check": {
"outputs": []
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
}
}
}