diff --git a/cms/__tests__/solutions.hook.unit.test.ts b/cms/__tests__/solutions.hook.unit.test.ts index 3fb0ad1..10d3f95 100644 --- a/cms/__tests__/solutions.hook.unit.test.ts +++ b/cms/__tests__/solutions.hook.unit.test.ts @@ -68,7 +68,14 @@ describe('Solutions afterChange hook', () => { collection: 'solutions', status: 'success', supabaseId: generatedId, - metadata: expect.objectContaining({ featureCount: 2, slug: 'demo-solution' }), + metadata: expect.objectContaining({ + slug: 'demo-solution', + title: 'Consultoria IA', + description: 'Helping teams deliver AI responsibly.', + features: JSON.stringify(['Recurso 1', 'Second capability']), + active: true, + gradient: 'from-brand-blue to-brand-purple', + }), }) ); }); diff --git a/cms/src/collections/HomepageFeatures.ts b/cms/src/collections/HomepageFeatures.ts index cf811ae..9d5475d 100644 --- a/cms/src/collections/HomepageFeatures.ts +++ b/cms/src/collections/HomepageFeatures.ts @@ -1,109 +1,28 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; +import { normalizeUrl } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; import { resolveLocalizedText } from '../utilities/localization'; import { buildSpaPreviewUrl } from '../utilities/preview'; -import { logCmsSyncEvent } from '../utilities/monitoring'; +import { publicReadAuthWrite, supabaseIdField, activeField } from '../utilities/collection-helpers'; const ICON_OPTIONS = ['Brain', 'Zap', 'Shield', 'Users', 'Globe', 'Laptop']; -const normalizeUrl = (value: unknown): string | null => { - if (typeof value !== 'string' || value.trim().length === 0) { - return null; - } - - try { - const parsed = new URL(value.trim()); - return parsed.toString(); - } catch { - return null; - } -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const title = resolveLocalizedText(doc.title); - const description = resolveLocalizedText(doc.description); - const icon = ICON_OPTIONS.includes(doc.icon) ? doc.icon : ICON_OPTIONS[0]; - const url = normalizeUrl(doc.url); - const orderIndex = Number.parseInt(String(doc.orderIndex ?? '0'), 10); - const active = !!doc.active; - - const queryWithId = ` - insert into public.homepage_features (id, title, description, icon, url, order_index, active, updated_at) - values ($1::uuid,$2,$3,$4,$5,$6,$7, now()) - on conflict (id) do update set - title=excluded.title, - description=excluded.description, - icon=excluded.icon, - url=excluded.url, - order_index=excluded.order_index, - active=excluded.active, - updated_at=now() - returning id`; - - const queryWithoutId = ` - insert into public.homepage_features (title, description, icon, url, order_index, active, updated_at) - values ($1,$2,$3,$4,$5,$6, now()) - on conflict (title) do update set - description=excluded.description, - icon=excluded.icon, - url=excluded.url, - order_index=excluded.order_index, - active=excluded.active, - updated_at=now() - returning id`; - - const paramsWithId = [ - supabaseId, - title, - description, - icon, - url, - orderIndex, - active, - ]; - - const paramsWithoutId = [title, description, icon, url, orderIndex, active]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); - - const generatedId = result?.rows?.[0]?.id ?? null; - - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'homepageFeatures', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); - } - - await logCmsSyncEvent({ - collection: 'homepageFeatures', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { icon, orderIndex, active, urlPresent: Boolean(url) }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing homepage feature'; - await logCmsSyncEvent({ - collection: 'homepageFeatures', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { icon, orderIndex }, - }); - throw error; - } -}; +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'homepageFeatures', + tableName: 'homepage_features', + conflictFieldWithoutId: 'title', + extractData: (doc: any) => { + const title = resolveLocalizedText(doc.title); + const description = resolveLocalizedText(doc.description); + const icon = ICON_OPTIONS.includes(doc.icon) ? doc.icon : ICON_OPTIONS[0]; + const url = normalizeUrl(doc.url); + const order_index = Number.parseInt(String(doc.orderIndex ?? '0'), 10); + const active = !!doc.active; + + return { title, description, icon, url, order_index, active }; + }, +}); const HomepageFeatures: CollectionConfig = { slug: 'homepageFeatures', @@ -111,18 +30,9 @@ const HomepageFeatures: CollectionConfig = { useAsTitle: 'title', preview: () => buildSpaPreviewUrl('/'), }, - access: { - read: () => true, - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: publicReadAuthWrite, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'title', type: 'text', required: true, localized: true }, { name: 'description', type: 'textarea', required: true, localized: true }, { @@ -168,11 +78,7 @@ const HomepageFeatures: CollectionConfig = { return true; }, }, - { - name: 'active', - type: 'checkbox', - defaultValue: true, - }, + activeField, ], hooks: { afterChange: [upsertIntoSupabase] }, }; diff --git a/cms/src/collections/Leads.ts b/cms/src/collections/Leads.ts index 204dd22..2015247 100644 --- a/cms/src/collections/Leads.ts +++ b/cms/src/collections/Leads.ts @@ -1,115 +1,41 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; -import { logCmsSyncEvent } from '../utilities/monitoring'; - -const normalizeEmail = (value: unknown): string | null => { - if (typeof value !== 'string') { - return null; - } - - const trimmed = value.trim().toLowerCase(); - if (trimmed.length === 0) { - return null; - } - - const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailPattern.test(trimmed) ? trimmed : null; -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const name = typeof doc.name === 'string' ? doc.name.trim() : ''; - const email = normalizeEmail(doc.email); - const company = typeof doc.company === 'string' && doc.company.trim().length > 0 ? doc.company.trim() : null; - const project = typeof doc.project === 'string' && doc.project.trim().length > 0 ? doc.project.trim() : null; - const message = typeof doc.message === 'string' ? doc.message.trim() : ''; - - if (!name) { - throw new Error('Name is required.'); - } - - if (!email) { - throw new Error('A valid email address is required.'); - } - - if (!message) { - throw new Error('Message cannot be empty.'); - } - - const queryWithId = ` - insert into public.leads (id, name, email, company, project, message) - values ($1::uuid,$2,$3,$4,$5,$6) - on conflict (id) do update set - name=excluded.name, - email=excluded.email, - company=excluded.company, - project=excluded.project, - message=excluded.message - returning id`; - - const queryWithoutId = ` - insert into public.leads (name, email, company, project, message) - values ($1,$2,$3,$4,$5) - returning id`; - - const paramsWithId = [supabaseId, name, email, company, project, message]; - const paramsWithoutId = [name, email, company, project, message]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); +import { normalizeEmail } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; +import { adminOnlyAccess, supabaseIdField } from '../utilities/collection-helpers'; + +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'leads', + tableName: 'leads', + extractData: (doc: any) => { + const name = typeof doc.name === 'string' ? doc.name.trim() : ''; + const email = normalizeEmail(doc.email); + const company = typeof doc.company === 'string' && doc.company.trim().length > 0 ? doc.company.trim() : null; + const project = typeof doc.project === 'string' && doc.project.trim().length > 0 ? doc.project.trim() : null; + const message = typeof doc.message === 'string' ? doc.message.trim() : ''; + + if (!name) { + throw new Error('Name is required.'); + } - const generatedId = result?.rows?.[0]?.id ?? null; + if (!email) { + throw new Error('A valid email address is required.'); + } - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'leads', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); + if (!message) { + throw new Error('Message cannot be empty.'); } - await logCmsSyncEvent({ - collection: 'leads', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { email, hasCompany: Boolean(company), hasProject: Boolean(project) }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing lead'; - await logCmsSyncEvent({ - collection: 'leads', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { email }, - }); - throw error; - } -}; + return { name, email, company, project, message }; + }, +}); const Leads: CollectionConfig = { slug: 'leads', admin: { useAsTitle: 'name' }, - access: { - read: ({ req }: { req: any }) => Boolean(req.user), - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: adminOnlyAccess, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'name', type: 'text', diff --git a/cms/src/collections/NewsletterSubscribers.ts b/cms/src/collections/NewsletterSubscribers.ts index 21cd285..9a75575 100644 --- a/cms/src/collections/NewsletterSubscribers.ts +++ b/cms/src/collections/NewsletterSubscribers.ts @@ -1,104 +1,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; -import { logCmsSyncEvent } from '../utilities/monitoring'; - -const normalizeEmail = (value: unknown): string | null => { - if (typeof value !== 'string') { - return null; - } - - const trimmed = value.trim().toLowerCase(); - if (trimmed.length === 0) { - return null; - } - - const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailPattern.test(trimmed) ? trimmed : null; -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const email = normalizeEmail(doc.email); - const active = !!doc.active; - - if (!email) { - throw new Error('A valid email address is required.'); - } - - const queryWithId = ` - insert into public.newsletter_subscribers (id, email, active) - values ($1::uuid,$2,$3) - on conflict (id) do update set - email=excluded.email, - active=excluded.active - returning id`; - - const queryWithoutId = ` - insert into public.newsletter_subscribers (email, active) - values ($1,$2) - on conflict (email) do update set - active=excluded.active, - email=excluded.email - returning id`; - - const paramsWithId = [supabaseId, email, active]; - const paramsWithoutId = [email, active]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); - - const generatedId = result?.rows?.[0]?.id ?? null; - - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'newsletterSubscribers', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); +import { normalizeEmail } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; +import { adminOnlyAccess, supabaseIdField, activeField } from '../utilities/collection-helpers'; + +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'newsletterSubscribers', + tableName: 'newsletter_subscribers', + conflictFieldWithoutId: 'email', + extractData: (doc: any) => { + const email = normalizeEmail(doc.email); + const active = !!doc.active; + + if (!email) { + throw new Error('A valid email address is required.'); } - await logCmsSyncEvent({ - collection: 'newsletterSubscribers', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { email, active }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing newsletter subscriber'; - await logCmsSyncEvent({ - collection: 'newsletterSubscribers', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { email }, - }); - throw error; - } -}; + return { email, active }; + }, +}); const NewsletterSubscribers: CollectionConfig = { slug: 'newsletterSubscribers', admin: { useAsTitle: 'email' }, - access: { - read: ({ req }: { req: any }) => Boolean(req.user), - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: adminOnlyAccess, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'email', type: 'text', @@ -112,11 +39,7 @@ const NewsletterSubscribers: CollectionConfig = { return true; }, }, - { - name: 'active', - type: 'checkbox', - defaultValue: true, - }, + activeField, ], hooks: { afterChange: [upsertIntoSupabase] }, }; diff --git a/cms/src/collections/Repositories.ts b/cms/src/collections/Repositories.ts index e3ce15e..49a5f26 100644 --- a/cms/src/collections/Repositories.ts +++ b/cms/src/collections/Repositories.ts @@ -1,125 +1,29 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; +import { normalizeUrl, extractTags } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; import { resolveLocalizedText } from '../utilities/localization'; import { buildSpaPreviewUrl } from '../utilities/preview'; -import { logCmsSyncEvent } from '../utilities/monitoring'; - -const normalizeUrl = (value: unknown): string | null => { - if (typeof value !== 'string' || value.trim().length === 0) { - return null; - } - - try { - const parsed = new URL(value.trim()); - return parsed.toString(); - } catch { - return null; - } -}; - -const extractTags = (tags: any): string[] => { - if (!Array.isArray(tags)) { - return []; - } - - const unique = new Set(); - - for (const tag of tags) { - let value: string | null = null; - - if (tag && typeof tag === 'object') { - if ('label' in tag) { - value = resolveLocalizedText(tag.label).trim(); - } else if ('value' in tag) { - value = resolveLocalizedText(tag.value).trim(); - } else if ('tag' in tag) { - value = resolveLocalizedText(tag.tag).trim(); - } - } else if (typeof tag === 'string') { - value = tag.trim(); - } - - if (value && value.length > 0) { - unique.add(value); - } - } - - return Array.from(unique); -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const name = resolveLocalizedText(doc.name); - const description = resolveLocalizedText(doc.description); - const github_url = normalizeUrl(doc.githubUrl); - const demo_url = normalizeUrl(doc.demoUrl); - const tags = extractTags(doc.tags); - const active = !!doc.active; - - if (!github_url) { - throw new Error('A valid GitHub URL is required for repositories.'); - } - - const queryWithId = ` - insert into public.repositories (id, name, description, github_url, demo_url, tags, active, updated_at) - values ($1::uuid,$2,$3,$4,$5,$6,$7, now()) - on conflict (id) do update set - name=excluded.name, - description=excluded.description, - github_url=excluded.github_url, - demo_url=excluded.demo_url, - tags=excluded.tags, - active=excluded.active, - updated_at=now() - returning id`; - - const queryWithoutId = ` - insert into public.repositories (name, description, github_url, demo_url, tags, active, updated_at) - values ($1,$2,$3,$4,$5,$6, now()) - returning id`; - - const paramsWithId = [supabaseId, name, description, github_url, demo_url, tags, active]; - const paramsWithoutId = [name, description, github_url, demo_url, tags, active]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); - - const generatedId = result?.rows?.[0]?.id ?? null; - - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'repositories', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); +import { publicReadAuthWrite, supabaseIdField, activeField } from '../utilities/collection-helpers'; + +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'repositories', + tableName: 'repositories', + extractData: (doc: any) => { + const name = resolveLocalizedText(doc.name); + const description = resolveLocalizedText(doc.description); + const github_url = normalizeUrl(doc.githubUrl); + const demo_url = normalizeUrl(doc.demoUrl); + const tags = extractTags(doc.tags, resolveLocalizedText); + const active = !!doc.active; + + if (!github_url) { + throw new Error('A valid GitHub URL is required for repositories.'); } - await logCmsSyncEvent({ - collection: 'repositories', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { github_url, tagsCount: tags.length, active }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing repository'; - await logCmsSyncEvent({ - collection: 'repositories', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { github_url }, - }); - throw error; - } -}; + return { name, description, github_url, demo_url, tags, active }; + }, +}); const Repositories: CollectionConfig = { slug: 'repositories', @@ -127,18 +31,9 @@ const Repositories: CollectionConfig = { useAsTitle: 'name', preview: () => buildSpaPreviewUrl('/projects'), }, - access: { - read: () => true, - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: publicReadAuthWrite, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'name', type: 'text', required: true, localized: true }, { name: 'description', type: 'textarea', required: true, localized: true }, { @@ -185,11 +80,7 @@ const Repositories: CollectionConfig = { }, ], }, - { - name: 'active', - type: 'checkbox', - defaultValue: true, - }, + activeField, ], hooks: { afterChange: [upsertIntoSupabase] }, }; diff --git a/cms/src/collections/SiteSettings.ts b/cms/src/collections/SiteSettings.ts index b776b95..882189c 100644 --- a/cms/src/collections/SiteSettings.ts +++ b/cms/src/collections/SiteSettings.ts @@ -1,93 +1,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; +import { serializeValue } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; import { resolveOptionalLocalizedText } from '../utilities/localization'; import { buildSpaPreviewUrl } from '../utilities/preview'; -import { logCmsSyncEvent } from '../utilities/monitoring'; +import { adminOnlyAccess, supabaseIdField } from '../utilities/collection-helpers'; -const serializeValue = (value: any): string => { - if (typeof value === 'string') { - try { - JSON.parse(value); - return value; - } catch { - return JSON.stringify(value); - } - } +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'siteSettings', + tableName: 'site_settings', + conflictFieldWithoutId: 'key', + jsonbFields: ['value'], + extractData: (doc: any) => { + const key = doc.key; + const description = resolveOptionalLocalizedText(doc.description); + const value = serializeValue(doc.value); - if (value === null || value === undefined) { - return JSON.stringify({}); - } - - return JSON.stringify(value); -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const key = doc.key; - const description = resolveOptionalLocalizedText(doc.description); - const value = serializeValue(doc.value); - - const queryWithId = ` - insert into public.site_settings (id, key, value, description, updated_at) - values ($1::uuid,$2,$3::jsonb,$4, now()) - on conflict (id) do update set - key=excluded.key, - value=excluded.value, - description=excluded.description, - updated_at=now() - returning id`; - - const queryWithoutId = ` - insert into public.site_settings (key, value, description, updated_at) - values ($1,$2::jsonb,$3, now()) - on conflict (key) do update set - value=excluded.value, - description=excluded.description, - updated_at=now() - returning id`; - - const paramsWithId = [supabaseId, key, value, description]; - const paramsWithoutId = [key, value, description]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); - - const generatedId = result?.rows?.[0]?.id ?? null; - - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'siteSettings', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); - } - - await logCmsSyncEvent({ - collection: 'siteSettings', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { key, hasDescription: Boolean(description) }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing site setting'; - await logCmsSyncEvent({ - collection: 'siteSettings', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { key }, - }); - throw error; - } -}; + return { key, value, description }; + }, +}); const SiteSettings: CollectionConfig = { slug: 'siteSettings', @@ -95,18 +26,9 @@ const SiteSettings: CollectionConfig = { useAsTitle: 'key', preview: () => buildSpaPreviewUrl('/contact'), }, - access: { - read: ({ req }: { req: any }) => Boolean(req.user), - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: adminOnlyAccess, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'key', type: 'text', diff --git a/cms/src/collections/Solutions.ts b/cms/src/collections/Solutions.ts index 4c59380..2364f65 100644 --- a/cms/src/collections/Solutions.ts +++ b/cms/src/collections/Solutions.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; +import { extractFeatureList } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; import { resolveLocalizedText, resolveOptionalLocalizedText } from '../utilities/localization'; import { buildSpaPreviewUrl } from '../utilities/preview'; -import { logCmsSyncEvent } from '../utilities/monitoring'; +import { publicReadAuthWrite, supabaseIdField, activeField } from '../utilities/collection-helpers'; const gradientOptions = [ 'from-brand-purple to-brand-blue', @@ -12,130 +13,23 @@ const gradientOptions = [ 'from-brand-orange to-brand-pink', ]; -const extractFeatureList = (features: any): string[] => { - if (!Array.isArray(features)) { - return []; - } - - const values = features - .map((feature) => { - if (feature && typeof feature === 'object') { - if ('content' in feature) { - return resolveOptionalLocalizedText(feature.content); - } - - if ('value' in feature) { - return resolveOptionalLocalizedText(feature.value); - } - - if ('label' in feature) { - return resolveOptionalLocalizedText(feature.label); - } - } - - return resolveOptionalLocalizedText(feature); - }) - .filter((item): item is string => Boolean(item && item.trim().length > 0)); - - return values; -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const slug = doc.slug; - const title = resolveLocalizedText(doc.title); - const description = resolveLocalizedText(doc.description); - const image_url = doc?.image?.url ?? null; - const features = extractFeatureList(doc.features); - const active = !!doc.active; - const gradient = doc.gradient ?? null; - - const queryWithId = ` - insert into public.solutions (id, slug, title, description, image_url, features, gradient, active, updated_at) - values ($1::uuid,$2,$3,$4,$5,$6::jsonb,$7,$8, now()) - on conflict (id) do update set - slug=excluded.slug, - title=excluded.title, - description=excluded.description, - image_url=excluded.image_url, - features=excluded.features, - gradient=excluded.gradient, - active=excluded.active, - updated_at=now() - returning id`; - - const queryWithoutId = ` - insert into public.solutions (slug, title, description, image_url, features, gradient, active, updated_at) - values ($1,$2,$3,$4,$5::jsonb,$6,$7, now()) - on conflict (slug) do update set - title=excluded.title, - description=excluded.description, - image_url=excluded.image_url, - features=excluded.features, - gradient=excluded.gradient, - active=excluded.active, - updated_at=now() - returning id`; - - const paramsWithId = [ - supabaseId, - slug, - title, - description, - image_url, - JSON.stringify(features ?? []), - gradient, - active, - ]; - - const paramsWithoutId = [ - slug, - title, - description, - image_url, - JSON.stringify(features ?? []), - gradient, - active, - ]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); - - const generatedId = result?.rows?.[0]?.id ?? null; - - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'solutions', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); - } - - await logCmsSyncEvent({ - collection: 'solutions', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { slug, hasImage: Boolean(image_url), featureCount: features.length }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing solution'; - await logCmsSyncEvent({ - collection: 'solutions', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { slug }, - }); - throw error; - } -}; +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'solutions', + tableName: 'solutions', + conflictFieldWithoutId: 'slug', + jsonbFields: ['features'], + extractData: (doc: any) => { + const slug = doc.slug; + const title = resolveLocalizedText(doc.title); + const description = resolveLocalizedText(doc.description); + const image_url = doc?.image?.url ?? null; + const features = JSON.stringify(extractFeatureList(doc.features, resolveOptionalLocalizedText) ?? []); + const active = !!doc.active; + const gradient = doc.gradient ?? null; + + return { slug, title, description, image_url, features, gradient, active }; + }, +}); const Solutions: CollectionConfig = { slug: 'solutions', @@ -148,18 +42,9 @@ const Solutions: CollectionConfig = { : '/solutions' ), }, - access: { - read: () => true, - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: publicReadAuthWrite, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'slug', type: 'text', @@ -207,11 +92,7 @@ const Solutions: CollectionConfig = { defaultValue: gradientOptions[0], options: gradientOptions.map((value) => ({ label: value, value })), }, - { - name: 'active', - type: 'checkbox', - defaultValue: true, - }, + activeField, ], hooks: { afterChange: [upsertIntoSupabase] }, }; diff --git a/cms/src/collections/TeamMembers.ts b/cms/src/collections/TeamMembers.ts index 0dce85c..dbf6bac 100644 --- a/cms/src/collections/TeamMembers.ts +++ b/cms/src/collections/TeamMembers.ts @@ -1,91 +1,25 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CollectionConfig } from 'payload'; -import { pool } from '../utilities/pool'; +import { normalizeUrl } from '../utilities/validation'; +import { createUpsertHook } from '../utilities/upsert-factory'; import { resolveLocalizedText, resolveOptionalLocalizedText } from '../utilities/localization'; import { buildSpaPreviewUrl } from '../utilities/preview'; -import { logCmsSyncEvent } from '../utilities/monitoring'; - -const normalizeUrl = (value: unknown): string | null => { - if (typeof value !== 'string' || value.trim().length === 0) { - return null; - } - - try { - const parsed = new URL(value.trim()); - return parsed.toString(); - } catch { - return null; - } -}; - -const upsertIntoSupabase = async ({ doc, req }: { doc: any; req: any }) => { - const supabaseId = doc.supabaseId ?? null; - const name = resolveLocalizedText(doc.name); - const role = resolveLocalizedText(doc.role); - const bio = resolveOptionalLocalizedText(doc.bio); - const image_url = doc?.photo?.url ?? null; - const linkedin_url = normalizeUrl(doc.linkedinUrl); - const active = !!doc.active; - - const queryWithId = ` - insert into public.team_members (id, name, role, bio, image_url, linkedin_url, active, updated_at) - values ($1::uuid,$2,$3,$4,$5,$6,$7, now()) - on conflict (id) do update set - name=excluded.name, - role=excluded.role, - bio=excluded.bio, - image_url=excluded.image_url, - linkedin_url=excluded.linkedin_url, - active=excluded.active, - updated_at=now() - returning id`; - - const queryWithoutId = ` - insert into public.team_members (name, role, bio, image_url, linkedin_url, active, updated_at) - values ($1,$2,$3,$4,$5,$6, now()) - returning id`; - - const paramsWithId = [supabaseId, name, role, bio, image_url, linkedin_url, active]; - const paramsWithoutId = [name, role, bio, image_url, linkedin_url, active]; - - try { - const result = supabaseId - ? await pool.query(queryWithId, paramsWithId) - : await pool.query(queryWithoutId, paramsWithoutId); - - const generatedId = result?.rows?.[0]?.id ?? null; - - if (!supabaseId && generatedId && req?.payload) { - await req.payload.update({ - collection: 'teamMembers', - id: doc.id, - data: { supabaseId: generatedId }, - depth: 0, - }); - } - - await logCmsSyncEvent({ - collection: 'teamMembers', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId: (supabaseId ?? generatedId) ?? null, - status: 'success', - metadata: { hasBio: Boolean(bio), linkedinConnected: Boolean(linkedin_url), active }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while syncing team member'; - await logCmsSyncEvent({ - collection: 'teamMembers', - action: 'upsert', - payloadId: doc.id ?? null, - supabaseId, - status: 'error', - message, - metadata: { linkedin_url }, - }); - throw error; - } -}; +import { publicReadAuthWrite, supabaseIdField, activeField } from '../utilities/collection-helpers'; + +const upsertIntoSupabase = createUpsertHook({ + collectionName: 'teamMembers', + tableName: 'team_members', + extractData: (doc: any) => { + const name = resolveLocalizedText(doc.name); + const role = resolveLocalizedText(doc.role); + const bio = resolveOptionalLocalizedText(doc.bio); + const image_url = doc?.photo?.url ?? null; + const linkedin_url = normalizeUrl(doc.linkedinUrl); + const active = !!doc.active; + + return { name, role, bio, image_url, linkedin_url, active }; + }, +}); const TeamMembers: CollectionConfig = { slug: 'teamMembers', @@ -93,18 +27,9 @@ const TeamMembers: CollectionConfig = { useAsTitle: 'name', preview: () => buildSpaPreviewUrl('/about'), }, - access: { - read: () => true, - create: ({ req }: { req: any }) => Boolean(req.user), - update: ({ req }: { req: any }) => Boolean(req.user), - delete: ({ req }: { req: any }) => Boolean(req.user), - }, + access: publicReadAuthWrite, fields: [ - { - name: 'supabaseId', - type: 'text', - admin: { hidden: true }, - }, + supabaseIdField, { name: 'name', type: 'text', required: true, localized: true }, { name: 'role', type: 'text', required: true, localized: true }, { name: 'bio', type: 'textarea', localized: true }, @@ -128,11 +53,7 @@ const TeamMembers: CollectionConfig = { return true; }, }, - { - name: 'active', - type: 'checkbox', - defaultValue: true, - }, + activeField, ], hooks: { afterChange: [upsertIntoSupabase] }, }; diff --git a/cms/src/utilities/collection-helpers.ts b/cms/src/utilities/collection-helpers.ts new file mode 100644 index 0000000..1d3d2c4 --- /dev/null +++ b/cms/src/utilities/collection-helpers.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Access, Field } from 'payload'; + +/** + * Standard access control that requires authentication + */ +export const authenticatedAccess: Access = ({ req }: { req: any }) => Boolean(req.user); + +/** + * Public read access + */ +export const publicReadAccess: Access = () => true; + +/** + * Standard access control for collections with public read + * and authenticated write/update/delete + */ +export const publicReadAuthWrite = { + read: publicReadAccess, + create: authenticatedAccess, + update: authenticatedAccess, + delete: authenticatedAccess, +}; + +/** + * Standard access control for admin-only collections + */ +export const adminOnlyAccess = { + read: authenticatedAccess, + create: authenticatedAccess, + update: authenticatedAccess, + delete: authenticatedAccess, +}; + +/** + * Hidden Supabase ID field used across all synced collections + */ +export const supabaseIdField: Field = { + name: 'supabaseId', + type: 'text', + admin: { hidden: true }, +}; + +/** + * Standard active/inactive checkbox field + */ +export const activeField: Field = { + name: 'active', + type: 'checkbox', + defaultValue: true, +}; diff --git a/cms/src/utilities/upsert-factory.ts b/cms/src/utilities/upsert-factory.ts new file mode 100644 index 0000000..bb42d55 --- /dev/null +++ b/cms/src/utilities/upsert-factory.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { pool } from './pool'; +import { logCmsSyncEvent } from './monitoring'; + +/** + * Configuration for the upsert factory + */ +export interface UpsertConfig { + collectionName: string; + tableName: string; + extractData: (doc: any) => Record; + conflictFieldWithId?: string; + conflictFieldWithoutId?: string; + jsonbFields?: string[]; +} + +/** + * Creates a reusable upsert hook for Payload CMS collections + * This factory reduces code duplication across collection files + */ +export const createUpsertHook = (config: UpsertConfig) => { + const { + collectionName, + tableName, + extractData, + conflictFieldWithId = 'id', + conflictFieldWithoutId, + jsonbFields = [], + } = config; + + return async ({ doc, req }: { doc: any; req: any }) => { + const supabaseId = doc.supabaseId ?? null; + const data = extractData(doc); + + // Separate supabaseId from other data fields + const { supabaseId: _, ...dataFields } = data; + + const fieldNames = Object.keys(dataFields); + const fieldValues = Object.values(dataFields); + + // Build the field list for the INSERT clause + const insertFields = fieldNames.join(', '); + + // Build the UPDATE clause (exclude conflict fields from updates) + const updateFields = fieldNames + .filter(f => f !== conflictFieldWithId && f !== conflictFieldWithoutId) + .map(f => `${f}=excluded.${f}`) + .join(',\n '); + + // Build parameter placeholders with type casting for special fields + const buildPlaceholders = (offset: number) => + fieldNames.map((fieldName, i) => { + const paramNum = i + offset; + // Handle JSONB fields + if (jsonbFields.includes(fieldName)) { + return `$${paramNum}::jsonb`; + } + return `$${paramNum}`; + }).join(', '); + + let query: string; + let params: any[]; + + if (supabaseId) { + // Insert with existing ID + const placeholders = buildPlaceholders(2); + query = ` + insert into public.${tableName} (id, ${insertFields}) + values ($1::uuid, ${placeholders}) + on conflict (${conflictFieldWithId}) do update set + ${updateFields}, + updated_at=now() + returning id`; + params = [supabaseId, ...fieldValues]; + } else { + // Insert without ID + const placeholders = buildPlaceholders(1); + const conflictField = conflictFieldWithoutId || conflictFieldWithId; + query = ` + insert into public.${tableName} (${insertFields}) + values (${placeholders}) + on conflict (${conflictField}) do update set + ${updateFields}, + updated_at=now() + returning id`; + params = fieldValues; + } + + try { + const result = await pool.query(query, params); + const generatedId = result?.rows?.[0]?.id ?? null; + + // Update the Payload document with the generated Supabase ID + if (!supabaseId && generatedId && req?.payload) { + await req.payload.update({ + collection: collectionName, + id: doc.id, + data: { supabaseId: generatedId }, + depth: 0, + }); + } + + await logCmsSyncEvent({ + collection: collectionName, + action: 'upsert', + payloadId: doc.id ?? null, + supabaseId: (supabaseId ?? generatedId) ?? null, + status: 'success', + metadata: dataFields, + }); + } catch (error) { + const message = error instanceof Error + ? error.message + : `Unknown error while syncing ${collectionName}`; + + await logCmsSyncEvent({ + collection: collectionName, + action: 'upsert', + payloadId: doc.id ?? null, + supabaseId, + status: 'error', + message, + metadata: dataFields, + }); + + throw error; + } + }; +}; diff --git a/cms/src/utilities/validation.ts b/cms/src/utilities/validation.ts new file mode 100644 index 0000000..d9e704c --- /dev/null +++ b/cms/src/utilities/validation.ts @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Shared validation and normalization utilities for CMS collections + */ + +/** + * Normalizes and validates a URL string + * @param value - The value to normalize + * @returns The normalized URL string or null if invalid + */ +export const normalizeUrl = (value: unknown): string | null => { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(value.trim()); + return parsed.toString(); + } catch { + return null; + } +}; + +/** + * Normalizes and validates an email address + * @param value - The value to normalize + * @returns The normalized email string or null if invalid + */ +export const normalizeEmail = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim().toLowerCase(); + if (trimmed.length === 0) { + return null; + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(trimmed) ? trimmed : null; +}; + +/** + * Extracts and normalizes tags from an array or object + * Handles multiple formats: objects with label/value/tag properties or plain strings + * @param tags - The tags array to extract from + * @param resolver - Optional function to resolve localized text + * @returns Array of unique, normalized tag strings + */ +export const extractTags = ( + tags: any, + resolver?: (value: any) => string +): string[] => { + if (!Array.isArray(tags)) { + return []; + } + + const unique = new Set(); + + for (const tag of tags) { + let value: string | null = null; + + if (tag && typeof tag === 'object') { + if ('label' in tag) { + value = resolver ? resolver(tag.label).trim() : String(tag.label).trim(); + } else if ('value' in tag) { + value = resolver ? resolver(tag.value).trim() : String(tag.value).trim(); + } else if ('tag' in tag) { + value = resolver ? resolver(tag.tag).trim() : String(tag.tag).trim(); + } + } else if (typeof tag === 'string') { + value = tag.trim(); + } + + if (value && value.length > 0) { + unique.add(value); + } + } + + return Array.from(unique); +}; + +/** + * Extracts a list of features/items from an array + * Handles multiple formats: objects with content/value/label properties or plain strings + * @param features - The features array to extract from + * @param resolver - Optional function to resolve localized text + * @returns Array of feature strings + */ +export const extractFeatureList = ( + features: any, + resolver?: (value: any) => string | null +): string[] => { + if (!Array.isArray(features)) { + return []; + } + + const values = features + .map((feature) => { + if (feature && typeof feature === 'object') { + if ('content' in feature) { + return resolver ? resolver(feature.content) : String(feature.content || ''); + } + + if ('value' in feature) { + return resolver ? resolver(feature.value) : String(feature.value || ''); + } + + if ('label' in feature) { + return resolver ? resolver(feature.label) : String(feature.label || ''); + } + } + + return resolver ? resolver(feature) : String(feature || ''); + }) + .filter((item): item is string => Boolean(item && item.trim().length > 0)); + + return values; +}; + +/** + * Serializes a value for JSON storage in the database + * If value is already a JSON string, returns it as-is + * Otherwise, stringifies the value + * @param value - The value to serialize + * @returns JSON string representation of the value + */ +export const serializeValue = (value: any): string => { + if (typeof value === 'string') { + try { + JSON.parse(value); + return value; + } catch { + return JSON.stringify(value); + } + } + + if (value === null || value === undefined) { + return JSON.stringify({}); + } + + return JSON.stringify(value); +};