diff --git a/.env.example b/.env.example index c1198c6..db72ada 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DATABASE_URL='mongodb://' PAYLOAD_SECRET= # openssl rand -hex 32 NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +DRAFT_SECRET_TOKEN= # openssl rand -hex 32 SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com SMTP_USER= diff --git a/src/access/anyone.ts b/src/access/anyone.ts index bf37c3a..2d54cc0 100644 --- a/src/access/anyone.ts +++ b/src/access/anyone.ts @@ -1,3 +1,17 @@ import type { Access } from 'payload' -export const anyone: Access = () => true +export const anyone: Access = ({ req: { user, headers } }) => { + if (user) { + return true + } + const authorization = headers.get('authorization') + if ( + process.env.DRAFT_SECRET_TOKEN && + authorization === `Bearer ${process.env.DRAFT_SECRET_TOKEN}` + ) { + return true + } + return { + or: [{ _status: { equals: 'published' } }, { _status: { exists: false } }], + } +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 2ed42a6..34dee8c 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -15,7 +15,6 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e import { FolderTypeField as FolderTypeField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client' import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard' import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' -import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { @@ -47,7 +46,5 @@ export const importMap = { '@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40, '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6, '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, - '@payloadcms/storage-s3/client#S3ClientUploadHandler': - S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, } diff --git a/src/collections/durianpy-website/sample-website-collection.index.ts b/src/collections/durianpy-website/sample-website-collection.index.ts index 63430d0..13ef0a6 100644 --- a/src/collections/durianpy-website/sample-website-collection.index.ts +++ b/src/collections/durianpy-website/sample-website-collection.index.ts @@ -4,8 +4,11 @@ import { AccessType } from '@/constants/accessTypes' import { COLLECTION_LABELS, getCollectionGroupLabel } from '@/constants/collections' import type { AccessArgs, CollectionConfig } from 'payload' -const checkSampleAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, 'sample', accessType) +const checkSampleAccess: ( + accessType?: AccessType, +) => (access: AccessArgs) => ReturnType = + (accessType?: AccessType) => (access: AccessArgs) => + checkCollectionAccess(access, 'sample', accessType) export const Sample: CollectionConfig = { slug: 'sample', diff --git a/src/constants/collections.ts b/src/constants/collections.ts index d091b70..f428ec5 100644 --- a/src/constants/collections.ts +++ b/src/constants/collections.ts @@ -48,7 +48,7 @@ export function getCollectionGroupLabel(groupSlug: CollectionGroupSlug) { } export const COLLECTION_GROUP_ITEMS: Record = { - 'durianpy-website': [], + 'durianpy-website': ['sample', 'users'], admin: ['users'], } as const diff --git a/src/endpoints/durianpy-website-types/index.ts b/src/endpoints/durianpy-website-types/index.ts new file mode 100644 index 0000000..57a5c26 --- /dev/null +++ b/src/endpoints/durianpy-website-types/index.ts @@ -0,0 +1,152 @@ +import fs from 'fs' +import path from 'path' + +import type { Endpoint } from 'payload' + +import { getCollectionGroupItems } from '@/constants/collections' + +const GROUP_SLUG = 'durianpy-website' as const + +function getCommentStartBefore(content: string, index: number): number { + const commentEnd = content.lastIndexOf('*/', index) + if (commentEnd === -1) return index + + const commentStart = content.lastIndexOf('/**', commentEnd) + if (commentStart === -1) return index + + const between = content.slice(commentEnd + 2, index) + return /^\s*$/.test(between) ? commentStart : index +} + +function extractInterfaceBlock(content: string, interfaceName: string): string | null { + const declaration = `export interface ${interfaceName}` + const declarationIndex = content.indexOf(declaration) + + if (declarationIndex === -1) return null + + const startIndex = getCommentStartBefore(content, declarationIndex) + const braceStart = content.indexOf('{', declarationIndex) + + if (braceStart === -1) return null + + let braceDepth = 0 + for (let i = braceStart; i < content.length; i += 1) { + const char = content[i] + + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + + if (braceDepth === 0) { + return content.slice(startIndex, i + 1) + } + } + + return null +} + +function getCollectionTypeMapFromConfig(typesFileContent: string): Map { + const map = new Map() + const collectionsBlockMatch = typesFileContent.match(/collections:\s*\{([\s\S]*?)\n\s*\};/) + + if (!collectionsBlockMatch) return map + + const collectionsBlock = collectionsBlockMatch[1] + const collectionEntryRegex = /^\s*(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*([A-Za-z0-9_]+);\s*$/gm + + let entryMatch: RegExpExecArray | null + while ((entryMatch = collectionEntryRegex.exec(collectionsBlock)) !== null) { + const slug = entryMatch[1] ?? entryMatch[2] + const typeName = entryMatch[3] + + if (!slug || !typeName) continue + map.set(slug, typeName) + } + + return map +} + +export const durianpyWebsiteTypesEndpoint: Endpoint = { + method: 'get', + path: '/durianpy-website-types', + handler: async () => { + try { + const fullTypes = fs.readFileSync( + path.resolve(process.cwd(), 'src/payload-types.ts'), + 'utf-8', + ) + + const groupCollectionSlugs = [...getCollectionGroupItems(GROUP_SLUG)].sort((a, b) => + a.localeCompare(b), + ) + + const collectionTypeMap = getCollectionTypeMapFromConfig(fullTypes) + + const missingTypeMappings: string[] = [] + const selectedTypeNames = groupCollectionSlugs + .map((slug) => { + const typeName = collectionTypeMap.get(slug) + if (!typeName) missingTypeMappings.push(slug) + return typeName + }) + .filter((name): name is string => Boolean(name)) + + if (missingTypeMappings.length > 0) { + return new Response( + JSON.stringify({ + error: 'Missing collection to type mappings in payload-types.ts', + group: GROUP_SLUG, + missingCollectionSlugs: missingTypeMappings, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const missingInterfaceBlocks: string[] = [] + const extractedInterfaces = selectedTypeNames + .map((typeName) => { + const block = extractInterfaceBlock(fullTypes, typeName) + if (!block) missingInterfaceBlocks.push(typeName) + return block + }) + .filter((block): block is string => Boolean(block)) + + if (missingInterfaceBlocks.length > 0) { + return new Response( + JSON.stringify({ + error: 'Could not extract one or more interfaces from payload-types.ts', + group: GROUP_SLUG, + missingInterfaces: missingInterfaceBlocks, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const typeSyncHeader = `// Auto-generated from src/payload-types.ts\n// Group: ${GROUP_SLUG}\n// Collections: ${groupCollectionSlugs.join(', ')}` + const payload = `${typeSyncHeader}\n\n${extractedInterfaces.join('\n\n')}` + + return new Response(payload, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Failed to generate durianpy-website type sync payload', + message: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 261a467..90ec20c 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -14,6 +14,7 @@ import { plugins } from './plugins' import { defaultLexical } from '@/fields/defaultLexical' import { getServerSideURL, getServerSideOrigin } from './utilities/getURL' import { Sample } from './collections/durianpy-website/sample-website-collection.index' +import { durianpyWebsiteTypesEndpoint } from './endpoints/durianpy-website-types' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -94,6 +95,7 @@ export default buildConfig({ }), }), collections: [Media, Categories, Users, Sample], + endpoints: [durianpyWebsiteTypesEndpoint], cors: [getServerSideOrigin()].filter(Boolean).map((url) => { try { const { origin } = new URL(url!) diff --git a/tests/int/api.int.spec.ts b/tests/int/api.int.spec.ts index 9bd5adb..33fccbc 100644 --- a/tests/int/api.int.spec.ts +++ b/tests/int/api.int.spec.ts @@ -1,5 +1,6 @@ import { getPayload, Payload } from 'payload' import config from '@/payload.config' +import { GET as getDurianTypes } from '@/app/(payload)/api/durianpy-website-types/route' import { describe, it, beforeAll, expect } from 'vitest' @@ -17,4 +18,22 @@ describe('API', () => { }) expect(users).toBeDefined() }) + + it('returns only durianpy-website collection interfaces', async () => { + const response = await getDurianTypes() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/plain') + + const body = await response.text() + + expect(body).toContain('Group: durianpy-website') + expect(body).toContain('Collections: sample, users') + expect(body).toContain('export interface Sample') + expect(body).toContain('export interface User') + + // Ensure non-group collections are excluded. + expect(body).not.toContain('export interface Media') + expect(body).not.toContain('export interface Category') + }) })