@latte-macchiat-io/latte-payload 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +9 -0
- package/.env.example +29 -0
- package/.github/workflows/ci.yml +160 -0
- package/.github/workflows/publish.yml +126 -0
- package/.nvmrc +1 -0
- package/.prettierignore +2 -0
- package/.prettierrc +11 -0
- package/CHANGELOG.md +87 -0
- package/README.md +364 -0
- package/TESTING_AND_DOCUMENTATION_SETUP.md +348 -0
- package/dist/access/adminAccessOnly.d.ts +25 -0
- package/dist/access/adminAccessOnly.d.ts.map +1 -0
- package/dist/access/adminAccessOnly.js +11 -0
- package/dist/access/adminAccessOnly.js.map +1 -0
- package/dist/access/admins.d.ts +72 -0
- package/dist/access/admins.d.ts.map +1 -0
- package/dist/access/admins.js +76 -0
- package/dist/access/admins.js.map +1 -0
- package/dist/access/anyone.d.ts +35 -0
- package/dist/access/anyone.d.ts.map +1 -0
- package/dist/access/anyone.js +34 -0
- package/dist/access/anyone.js.map +1 -0
- package/dist/access/authenticated.d.ts +63 -0
- package/dist/access/authenticated.d.ts.map +1 -0
- package/dist/access/authenticated.js +68 -0
- package/dist/access/authenticated.js.map +1 -0
- package/dist/access/authenticatedAccessOnly.d.ts +13 -0
- package/dist/access/authenticatedAccessOnly.d.ts.map +1 -0
- package/dist/access/authenticatedAccessOnly.js +11 -0
- package/dist/access/authenticatedAccessOnly.js.map +1 -0
- package/dist/access/index.d.ts +7 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +7 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/publicReadAuthenticatedAccess.d.ts +13 -0
- package/dist/access/publicReadAuthenticatedAccess.d.ts.map +1 -0
- package/dist/access/publicReadAuthenticatedAccess.js +12 -0
- package/dist/access/publicReadAuthenticatedAccess.js.map +1 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.d.ts +31 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.d.ts.map +1 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.js +29 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.js.map +1 -0
- package/dist/collections/ContactMessages/index.d.ts +27 -0
- package/dist/collections/ContactMessages/index.d.ts.map +1 -0
- package/dist/collections/ContactMessages/index.js +81 -0
- package/dist/collections/ContactMessages/index.js.map +1 -0
- package/dist/collections/EmailTemplates/index.d.ts +26 -0
- package/dist/collections/EmailTemplates/index.d.ts.map +1 -0
- package/dist/collections/EmailTemplates/index.js +74 -0
- package/dist/collections/EmailTemplates/index.js.map +1 -0
- package/dist/collections/Media/index.d.ts +3 -0
- package/dist/collections/Media/index.d.ts.map +1 -0
- package/dist/collections/Media/index.js +22 -0
- package/dist/collections/Media/index.js.map +1 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.d.ts +3 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.d.ts.map +1 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.js +11 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.js.map +1 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.d.ts +2 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.d.ts.map +1 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.js +79 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.js.map +1 -0
- package/dist/collections/QueuedEmails/index.d.ts +3 -0
- package/dist/collections/QueuedEmails/index.d.ts.map +1 -0
- package/dist/collections/QueuedEmails/index.js +245 -0
- package/dist/collections/QueuedEmails/index.js.map +1 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.d.ts +51 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.d.ts.map +1 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.js +90 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.js.map +1 -0
- package/dist/collections/Users/auth-emails/verify-email.d.ts +51 -0
- package/dist/collections/Users/auth-emails/verify-email.d.ts.map +1 -0
- package/dist/collections/Users/auth-emails/verify-email.js +80 -0
- package/dist/collections/Users/auth-emails/verify-email.js.map +1 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.d.ts +3 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.d.ts.map +1 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.js +19 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.js.map +1 -0
- package/dist/collections/Users/index.d.ts +76 -0
- package/dist/collections/Users/index.d.ts.map +1 -0
- package/dist/collections/Users/index.js +116 -0
- package/dist/collections/Users/index.js.map +1 -0
- package/dist/collections/index.d.ts +10 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/collections/index.js +14 -0
- package/dist/collections/index.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +4 -0
- package/dist/components/index.js.map +1 -0
- package/dist/forms/states.d.ts +8 -0
- package/dist/forms/states.d.ts.map +1 -0
- package/dist/forms/states.js +16 -0
- package/dist/forms/states.js.map +1 -0
- package/dist/forms/translate-errors.d.ts +8 -0
- package/dist/forms/translate-errors.d.ts.map +1 -0
- package/dist/forms/translate-errors.js +11 -0
- package/dist/forms/translate-errors.js.map +1 -0
- package/dist/forms/validators.d.ts +10 -0
- package/dist/forms/validators.d.ts.map +1 -0
- package/dist/forms/validators.js +23 -0
- package/dist/forms/validators.js.map +1 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.d.ts +2 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.d.ts.map +1 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.js +5 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.js.map +1 -0
- package/dist/globals/PrivacyPolicy/index.d.ts +3 -0
- package/dist/globals/PrivacyPolicy/index.d.ts.map +1 -0
- package/dist/globals/PrivacyPolicy/index.js +31 -0
- package/dist/globals/PrivacyPolicy/index.js.map +1 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.d.ts +2 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.d.ts.map +1 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.js +5 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.js.map +1 -0
- package/dist/globals/TermsOfUse/index.d.ts +3 -0
- package/dist/globals/TermsOfUse/index.d.ts.map +1 -0
- package/dist/globals/TermsOfUse/index.js +31 -0
- package/dist/globals/TermsOfUse/index.js.map +1 -0
- package/dist/globals/index.d.ts +3 -0
- package/dist/globals/index.d.ts.map +1 -0
- package/dist/globals/index.js +3 -0
- package/dist/globals/index.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/tasks/index.d.ts +2 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +2 -0
- package/dist/tasks/index.js.map +1 -0
- package/dist/tasks/process-email-queue.d.ts +46 -0
- package/dist/tasks/process-email-queue.d.ts.map +1 -0
- package/dist/tasks/process-email-queue.js +199 -0
- package/dist/tasks/process-email-queue.js.map +1 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/slug.d.ts +2 -0
- package/dist/types/slug.d.ts.map +1 -0
- package/dist/types/slug.js +11 -0
- package/dist/types/slug.js.map +1 -0
- package/dist/utils/database-dates.d.ts +3 -0
- package/dist/utils/database-dates.d.ts.map +1 -0
- package/dist/utils/database-dates.js +6 -0
- package/dist/utils/database-dates.js.map +1 -0
- package/dist/utils/email/generate-email-html.d.ts +23 -0
- package/dist/utils/email/generate-email-html.d.ts.map +1 -0
- package/dist/utils/email/generate-email-html.js +42 -0
- package/dist/utils/email/generate-email-html.js.map +1 -0
- package/dist/utils/email/get-email-template.d.ts +8 -0
- package/dist/utils/email/get-email-template.d.ts.map +1 -0
- package/dist/utils/email/get-email-template.js +14 -0
- package/dist/utils/email/get-email-template.js.map +1 -0
- package/dist/utils/email/index.d.ts +4 -0
- package/dist/utils/email/index.d.ts.map +1 -0
- package/dist/utils/email/index.js +4 -0
- package/dist/utils/email/index.js.map +1 -0
- package/dist/utils/email/queue-email.d.ts +29 -0
- package/dist/utils/email/queue-email.d.ts.map +1 -0
- package/dist/utils/email/queue-email.js +79 -0
- package/dist/utils/email/queue-email.js.map +1 -0
- package/dist/utils/get-global.d.ts +6 -0
- package/dist/utils/get-global.d.ts.map +1 -0
- package/dist/utils/get-global.js +20 -0
- package/dist/utils/get-global.js.map +1 -0
- package/dist/utils/id-from-payload.d.ts +4 -0
- package/dist/utils/id-from-payload.d.ts.map +1 -0
- package/dist/utils/id-from-payload.js +10 -0
- package/dist/utils/id-from-payload.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/migrations.d.ts +2 -0
- package/dist/utils/migrations.d.ts.map +1 -0
- package/dist/utils/migrations.js +18 -0
- package/dist/utils/migrations.js.map +1 -0
- package/dist/utils/payload-client.d.ts +10 -0
- package/dist/utils/payload-client.d.ts.map +1 -0
- package/dist/utils/payload-client.js +14 -0
- package/dist/utils/payload-client.js.map +1 -0
- package/dist/utils/slugify.d.ts +8 -0
- package/dist/utils/slugify.d.ts.map +1 -0
- package/dist/utils/slugify.js +15 -0
- package/dist/utils/slugify.js.map +1 -0
- package/eslint.config.mjs +90 -0
- package/package.json +139 -0
- package/pnpm-workspace.yaml +4 -0
- package/src/access/adminAccessOnly.ts +13 -0
- package/src/access/admins.ts +78 -0
- package/src/access/anyone.ts +35 -0
- package/src/access/authenticated.ts +70 -0
- package/src/access/authenticatedAccessOnly.ts +13 -0
- package/src/access/index.ts +6 -0
- package/src/access/publicReadAuthenticatedAccess.ts +14 -0
- package/src/collections/ContactMessages/hooks/sendEmailNotification.ts +58 -0
- package/src/collections/ContactMessages/index.ts +100 -0
- package/src/collections/EmailTemplates/index.ts +89 -0
- package/src/collections/Media/index.ts +24 -0
- package/src/collections/QueuedEmails/components/HtmlViewer.tsx +16 -0
- package/src/collections/QueuedEmails/components/RetryEmailButtons.tsx +115 -0
- package/src/collections/QueuedEmails/index.ts +246 -0
- package/src/collections/Users/auth-emails/forgot-password-email.ts +135 -0
- package/src/collections/Users/auth-emails/verify-email.ts +123 -0
- package/src/collections/Users/hooks/ensureFirstUserIsAdmin.ts +22 -0
- package/src/collections/Users/index.ts +201 -0
- package/src/collections/index.ts +23 -0
- package/src/components/index.ts +3 -0
- package/src/forms/states.ts +23 -0
- package/src/forms/translate-errors.ts +13 -0
- package/src/forms/validators.ts +33 -0
- package/src/globals/PrivacyPolicy/hooks/revalidate-cache.ts +5 -0
- package/src/globals/PrivacyPolicy/index.ts +33 -0
- package/src/globals/TermsOfUse/hooks/revalidate-cache.ts +5 -0
- package/src/globals/TermsOfUse/index.ts +33 -0
- package/src/globals/index.ts +2 -0
- package/src/index.ts +26 -0
- package/src/tasks/index.ts +7 -0
- package/src/tasks/process-email-queue.ts +261 -0
- package/src/types/index.ts +15 -0
- package/src/types/slug.ts +11 -0
- package/src/utils/database-dates.ts +6 -0
- package/src/utils/email/generate-email-html.ts +63 -0
- package/src/utils/email/get-email-template.ts +18 -0
- package/src/utils/email/index.ts +3 -0
- package/src/utils/email/queue-email.ts +109 -0
- package/src/utils/get-global.ts +25 -0
- package/src/utils/id-from-payload.ts +11 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/migrations.ts +18 -0
- package/src/utils/payload-client.ts +16 -0
- package/src/utils/slugify.ts +21 -0
- package/tests/fixtures/email-template.html +58 -0
- package/tests/fixtures/sample-data.ts +56 -0
- package/tests/helpers/create-test-user.ts +37 -0
- package/tests/helpers/init-payload.ts +59 -0
- package/tests/setup.integration.ts +9 -0
- package/tests/setup.ts +4 -0
- package/tests/unit/access/adminAccessOnly.spec.ts +117 -0
- package/tests/unit/access/admins.spec.ts +68 -0
- package/tests/unit/access/anyone.spec.ts +28 -0
- package/tests/unit/access/authenticated.spec.ts +53 -0
- package/tests/unit/access/authenticatedAccessOnly.spec.ts +112 -0
- package/tests/unit/access/publicReadAuthenticatedAccess.spec.ts +112 -0
- package/tests/unit/forms/validators.spec.ts +348 -0
- package/tests/unit/utils/database-dates.spec.ts +97 -0
- package/tests/unit/utils/id-from-payload.spec.ts +142 -0
- package/tests/unit/utils/slugify.spec.ts +185 -0
- package/tsconfig.json +31 -0
- package/typedoc.json +40 -0
- package/vitest.config.ts +31 -0
- package/vitest.integration.config.ts +27 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported locales for the Latte Payload package
|
|
3
|
+
* Extend this type if you need additional locales
|
|
4
|
+
*/
|
|
5
|
+
export type Locale = 'en' | 'fr' | 'nl';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Email template codes
|
|
9
|
+
*/
|
|
10
|
+
export type EmailTemplateCode = 'verify-email' | 'password-lost' | string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Queued email status
|
|
14
|
+
*/
|
|
15
|
+
export type QueuedEmailStatus = 'pending' | 'sending' | 'sent' | 'failed';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { slugField as payLoadSlugField } from 'payload';
|
|
2
|
+
import { slugify } from '../utils/slugify';
|
|
3
|
+
|
|
4
|
+
export const slugField = payLoadSlugField({
|
|
5
|
+
name: 'slug',
|
|
6
|
+
useAsSlug: 'name',
|
|
7
|
+
position: 'sidebar',
|
|
8
|
+
required: true,
|
|
9
|
+
localized: true,
|
|
10
|
+
slugify: ({ valueToSlugify, req }) => slugify(valueToSlugify, { locale: req.locale ?? undefined }),
|
|
11
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html';
|
|
4
|
+
import { BasePayload } from 'payload';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { getEmailTemplateByCode } from './get-email-template';
|
|
8
|
+
import { EmailTemplateCode, Locale } from '../../types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate email HTML from a template stored in the database
|
|
12
|
+
*
|
|
13
|
+
* @param payload - Payload instance
|
|
14
|
+
* @param emailCode - Email template code
|
|
15
|
+
* @param variables - Variables to replace in the template (e.g., { name: 'John', url: 'https://...' })
|
|
16
|
+
* @param locale - Locale for the email
|
|
17
|
+
* @param emailTemplateHtmlPath - Optional path to the HTML template file (default: './emails/template.html')
|
|
18
|
+
* @returns Object with subject and body HTML
|
|
19
|
+
*/
|
|
20
|
+
export async function generateEmailHtmlFromTemplate({
|
|
21
|
+
payload,
|
|
22
|
+
emailCode,
|
|
23
|
+
variables,
|
|
24
|
+
locale,
|
|
25
|
+
emailTemplateHtmlPath = './emails/template.html',
|
|
26
|
+
}: {
|
|
27
|
+
payload: BasePayload;
|
|
28
|
+
emailCode: EmailTemplateCode;
|
|
29
|
+
variables: Record<string, string>;
|
|
30
|
+
locale: Locale;
|
|
31
|
+
emailTemplateHtmlPath?: string;
|
|
32
|
+
}): Promise<{ subject: string; body: string } | undefined> {
|
|
33
|
+
const emailTemplate = fs.readFileSync(path.resolve(emailTemplateHtmlPath), 'utf8');
|
|
34
|
+
|
|
35
|
+
const templateContent = await getEmailTemplateByCode({
|
|
36
|
+
locale: locale,
|
|
37
|
+
code: emailCode,
|
|
38
|
+
payload: payload,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!templateContent) {
|
|
42
|
+
payload.logger.error(`Email template with code ${emailCode} not found`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const emailContentAsHtml = convertLexicalToHTML({ data: templateContent.body });
|
|
46
|
+
|
|
47
|
+
// Replace {{variable}} by the actual value
|
|
48
|
+
const emailContent = emailContentAsHtml.replaceAll(/{{\s*([^}]+)\s*}}/g, (_match, p1) => variables[p1]);
|
|
49
|
+
|
|
50
|
+
// Quick fix to replace clickable links {escapeHTML(node.url)} with the variable url value
|
|
51
|
+
// This is a workaround until we have a better solution to handle links in emails
|
|
52
|
+
// TODO check if still needed after Payload CMS Rich Text Editor improvements
|
|
53
|
+
const emailContentWithLinks = emailContent.replace(/href={escapeHTML\(node\.(\w+)\)}/g, (_match, p1) => `href="${variables[p1]}"`);
|
|
54
|
+
|
|
55
|
+
const finalEmailBody = emailTemplate
|
|
56
|
+
.replaceAll('{{ emailContent }}', emailContentWithLinks)
|
|
57
|
+
.replaceAll('{{ emailSubject }}', templateContent.subject);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
subject: templateContent.subject,
|
|
61
|
+
body: finalEmailBody,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BasePayload } from 'payload';
|
|
2
|
+
import { Locale } from '../../types';
|
|
3
|
+
|
|
4
|
+
export async function getEmailTemplateByCode({ locale, code, payload }: { locale: Locale; code: string; payload: BasePayload }) {
|
|
5
|
+
const emailContentTemplateDocs = await payload.find({
|
|
6
|
+
collection: 'email-templates',
|
|
7
|
+
where: { code: { equals: code } },
|
|
8
|
+
limit: 1,
|
|
9
|
+
locale: locale,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (!emailContentTemplateDocs || emailContentTemplateDocs.totalDocs === 0) {
|
|
13
|
+
payload.logger.error(`Email template with code ${code} not found`);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return emailContentTemplateDocs.docs[0];
|
|
18
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { BasePayload } from 'payload';
|
|
4
|
+
import { generateEmailHtmlFromTemplate } from './generate-email-html';
|
|
5
|
+
import { EmailTemplateCode, Locale } from '../../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Queue an email to be sent by the email processor job.
|
|
9
|
+
* This function generates the email HTML from template and stores it in the queued-emails collection.
|
|
10
|
+
* The email will be picked up and sent by the process-email-queue job.
|
|
11
|
+
*
|
|
12
|
+
* @param payload - Payload instance
|
|
13
|
+
* @param to - Recipient email address
|
|
14
|
+
* @param cc - Optional CC email addresses
|
|
15
|
+
* @param emailCode - Email template code (e.g., 'verify-email', 'password-lost')
|
|
16
|
+
* @param variables - Variables to replace in the template
|
|
17
|
+
* @param locale - Locale for the email
|
|
18
|
+
* @param maxAttempts - Maximum number of retry attempts (default: 5)
|
|
19
|
+
* @param idempotencyKey - Optional unique key to prevent duplicate emails (e.g., "order-confirmation-123")
|
|
20
|
+
* @param emailTemplateHtmlPath - Optional path to the HTML template file (default: './emails/template.html')
|
|
21
|
+
*/
|
|
22
|
+
export async function queueEmail({
|
|
23
|
+
payload,
|
|
24
|
+
to,
|
|
25
|
+
cc,
|
|
26
|
+
emailCode,
|
|
27
|
+
variables,
|
|
28
|
+
locale,
|
|
29
|
+
maxAttempts = 5,
|
|
30
|
+
idempotencyKey,
|
|
31
|
+
emailTemplateHtmlPath,
|
|
32
|
+
}: {
|
|
33
|
+
payload: BasePayload;
|
|
34
|
+
to: string;
|
|
35
|
+
cc?: string[];
|
|
36
|
+
emailCode: EmailTemplateCode;
|
|
37
|
+
variables: Record<string, string>;
|
|
38
|
+
locale: Locale;
|
|
39
|
+
maxAttempts?: number;
|
|
40
|
+
idempotencyKey?: string;
|
|
41
|
+
emailTemplateHtmlPath?: string;
|
|
42
|
+
}) {
|
|
43
|
+
try {
|
|
44
|
+
// Check for existing email with the same idempotency key (if provided)
|
|
45
|
+
if (idempotencyKey) {
|
|
46
|
+
const existingEmails = await payload.find({
|
|
47
|
+
collection: 'queued-emails',
|
|
48
|
+
where: {
|
|
49
|
+
idempotencyKey: { equals: idempotencyKey },
|
|
50
|
+
},
|
|
51
|
+
limit: 1,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (existingEmails.docs.length > 0) {
|
|
55
|
+
const existingEmail = existingEmails.docs[0];
|
|
56
|
+
// If email is pending, sending, or sent, skip creating a duplicate
|
|
57
|
+
if (existingEmail.status === 'pending' || existingEmail.status === 'sending' || existingEmail.status === 'sent') {
|
|
58
|
+
payload.logger.info(
|
|
59
|
+
`Email ${emailCode} with idempotency key "${idempotencyKey}" already exists (status: ${existingEmail.status}). Skipping duplicate.`
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// If email failed, we'll allow retry by creating a new one (old one stays for debugging)
|
|
64
|
+
payload.logger.info(`Email ${emailCode} with idempotency key "${idempotencyKey}" previously failed. Creating new attempt.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Generate the email content from template
|
|
69
|
+
const emailContent = await generateEmailHtmlFromTemplate({
|
|
70
|
+
emailCode: emailCode,
|
|
71
|
+
locale: locale,
|
|
72
|
+
payload: payload,
|
|
73
|
+
variables: variables,
|
|
74
|
+
emailTemplateHtmlPath,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!emailContent) {
|
|
78
|
+
const error = `Email template with code ${emailCode} not found`;
|
|
79
|
+
payload.logger.error(error);
|
|
80
|
+
throw new Error(error);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
payload.logger.info(`Queueing email ${emailCode} to ${to} with subject "${emailContent.subject}"`);
|
|
84
|
+
|
|
85
|
+
// Create a record in the queued-emails collection
|
|
86
|
+
await payload.create({
|
|
87
|
+
collection: 'queued-emails',
|
|
88
|
+
data: {
|
|
89
|
+
idempotencyKey: idempotencyKey || null,
|
|
90
|
+
status: 'pending',
|
|
91
|
+
code: emailCode,
|
|
92
|
+
recipient: to,
|
|
93
|
+
cc: cc?.join(', '),
|
|
94
|
+
subject: emailContent.subject,
|
|
95
|
+
htmlBody: emailContent.body,
|
|
96
|
+
attemptCount: 0,
|
|
97
|
+
maxAttempts: maxAttempts,
|
|
98
|
+
lastAttemptAt: null,
|
|
99
|
+
sentAt: null,
|
|
100
|
+
lastError: null,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
payload.logger.info(`Email ${emailCode} queued successfully for ${to}`);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
payload.logger.error(`Error queueing email ${emailCode} to ${to}: ${error}`);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { unstable_cache } from 'next/cache';
|
|
4
|
+
import type { Config, GlobalSlug } from 'payload';
|
|
5
|
+
import { getPayloadClient } from './payload-client';
|
|
6
|
+
|
|
7
|
+
async function getGlobal<TSlug extends GlobalSlug>(slug: TSlug, config: Promise<Config> | Config, depth = 0): Promise<unknown> {
|
|
8
|
+
const payload = await getPayloadClient(config);
|
|
9
|
+
|
|
10
|
+
return payload.findGlobal({
|
|
11
|
+
slug,
|
|
12
|
+
depth,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Strongly typed cached global fetcher
|
|
18
|
+
*/
|
|
19
|
+
export async function getCachedGlobal<TSlug extends GlobalSlug>(slug: TSlug, config: Promise<Config> | Config, depth = 0): Promise<unknown> {
|
|
20
|
+
const cachedFn = unstable_cache(() => getGlobal(slug, config, depth), [slug], {
|
|
21
|
+
tags: [`global_${slug}`],
|
|
22
|
+
}) as () => Promise<unknown>;
|
|
23
|
+
|
|
24
|
+
return cachedFn();
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function getId(idOrObject: number | { id: number }): number {
|
|
2
|
+
if (typeof idOrObject === 'number') {
|
|
3
|
+
return idOrObject;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (typeof idOrObject === 'object' && idOrObject !== null) {
|
|
7
|
+
return idOrObject.id;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
throw new Error('Invalid ID or object');
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { getPayload } from 'payload';
|
|
4
|
+
import type { Config } from 'payload';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get Payload client instance
|
|
8
|
+
* This is a wrapper around Payload's getPayload function
|
|
9
|
+
*
|
|
10
|
+
* @param config - Your Payload configuration object
|
|
11
|
+
* @returns Payload client instance
|
|
12
|
+
*/
|
|
13
|
+
export async function getPayloadClient(config: Promise<Config> | Config) {
|
|
14
|
+
// @ts-expect-error - getPayload accepts Config but types show SanitizedConfig
|
|
15
|
+
return getPayload({ config });
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import slugifyPlugin from 'slugify';
|
|
2
|
+
|
|
3
|
+
// Custom slugify utility with configurable options
|
|
4
|
+
// Default options: trimming, lowercase conversion, and removal of special characters
|
|
5
|
+
export const slugify = (
|
|
6
|
+
val: string = '', // Default to empty string if no value is provided
|
|
7
|
+
options: {
|
|
8
|
+
trim?: boolean;
|
|
9
|
+
locale?: string;
|
|
10
|
+
lower?: boolean;
|
|
11
|
+
remove?: RegExp; // Allow custom removal of special characters
|
|
12
|
+
} = { trim: true, lower: true } // Default options
|
|
13
|
+
) => {
|
|
14
|
+
return slugifyPlugin(val, {
|
|
15
|
+
trim: options.trim ?? true, // Always trim leading/trailing spaces, fallback to true
|
|
16
|
+
lower: options.lower ?? true, // Always convert to lowercase, fallback to true
|
|
17
|
+
locale: options.locale ?? undefined, // Optional locale parameter
|
|
18
|
+
remove: /[*+~.()'"!:@¿¡©®—`^?;,<>/&|=#%$§]/g, // Default regex now includes "?"
|
|
19
|
+
...options, // Merge provided options with defaults
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="{{locale}}">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{{subject}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: Arial, sans-serif;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: #333;
|
|
12
|
+
max-width: 600px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
}
|
|
16
|
+
.header {
|
|
17
|
+
background-color: #f4f4f4;
|
|
18
|
+
padding: 20px;
|
|
19
|
+
text-align: center;
|
|
20
|
+
}
|
|
21
|
+
.content {
|
|
22
|
+
padding: 20px;
|
|
23
|
+
}
|
|
24
|
+
.footer {
|
|
25
|
+
background-color: #f4f4f4;
|
|
26
|
+
padding: 10px;
|
|
27
|
+
text-align: center;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
color: #666;
|
|
30
|
+
}
|
|
31
|
+
.button {
|
|
32
|
+
display: inline-block;
|
|
33
|
+
padding: 10px 20px;
|
|
34
|
+
background-color: #007bff;
|
|
35
|
+
color: #ffffff;
|
|
36
|
+
text-decoration: none;
|
|
37
|
+
border-radius: 5px;
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<div class="header">
|
|
43
|
+
<h1>Test Email Template</h1>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="content">
|
|
46
|
+
<p>Hello {{name}},</p>
|
|
47
|
+
<p>{{message}}</p>
|
|
48
|
+
{{#if actionUrl}}
|
|
49
|
+
<p>
|
|
50
|
+
<a href="{{actionUrl}}" class="button">{{actionText}}</a>
|
|
51
|
+
</p>
|
|
52
|
+
{{/if}}
|
|
53
|
+
</div>
|
|
54
|
+
<div class="footer">
|
|
55
|
+
<p>© {{year}} Latte Macchiat.io. All rights reserved.</p>
|
|
56
|
+
</div>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Locale } from '../../src/types';
|
|
2
|
+
|
|
3
|
+
export const sampleUsers = [
|
|
4
|
+
{
|
|
5
|
+
email: 'user1@example.com',
|
|
6
|
+
password: 'password123',
|
|
7
|
+
roles: ['user'],
|
|
8
|
+
name: 'User One',
|
|
9
|
+
locale: 'en' as Locale,
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
email: 'user2@example.com',
|
|
13
|
+
password: 'password123',
|
|
14
|
+
roles: ['user'],
|
|
15
|
+
name: 'User Two',
|
|
16
|
+
locale: 'fr' as Locale,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
email: 'admin@example.com',
|
|
20
|
+
password: 'admin123',
|
|
21
|
+
roles: ['admin', 'user'],
|
|
22
|
+
name: 'Admin User',
|
|
23
|
+
locale: 'en' as Locale,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const sampleEmailTemplates = [
|
|
28
|
+
{
|
|
29
|
+
code: 'welcome',
|
|
30
|
+
subject: {
|
|
31
|
+
en: 'Welcome to our platform!',
|
|
32
|
+
fr: 'Bienvenue sur notre plateforme!',
|
|
33
|
+
nl: 'Welkom op ons platform!',
|
|
34
|
+
},
|
|
35
|
+
html: '<h1>Welcome {{name}}!</h1><p>Thank you for joining us.</p>',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
code: 'test-template',
|
|
39
|
+
subject: {
|
|
40
|
+
en: 'Test Email',
|
|
41
|
+
fr: 'Email de test',
|
|
42
|
+
nl: 'Test email',
|
|
43
|
+
},
|
|
44
|
+
html: '<p>This is a test email for {{name}}</p>',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const sampleQueuedEmail = {
|
|
49
|
+
recipient: 'test@example.com',
|
|
50
|
+
subject: 'Test Email Subject',
|
|
51
|
+
htmlContent: '<p>Test email content</p>',
|
|
52
|
+
status: 'pending' as const,
|
|
53
|
+
locale: 'en' as Locale,
|
|
54
|
+
idempotencyKey: 'test-idempotency-key-123',
|
|
55
|
+
retries: 0,
|
|
56
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
|
|
3
|
+
export interface CreateTestUserParams {
|
|
4
|
+
email?: string;
|
|
5
|
+
password?: string;
|
|
6
|
+
roles?: string[];
|
|
7
|
+
name?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Helper to create a test user
|
|
12
|
+
*/
|
|
13
|
+
export async function createTestUser(payload: Payload, params: CreateTestUserParams = {}): Promise<any> {
|
|
14
|
+
const { email = 'test@example.com', password = 'password123', roles = ['user'], name = 'Test User' } = params;
|
|
15
|
+
|
|
16
|
+
return await payload.create({
|
|
17
|
+
collection: 'users',
|
|
18
|
+
data: {
|
|
19
|
+
email,
|
|
20
|
+
password,
|
|
21
|
+
roles,
|
|
22
|
+
name,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Helper to create an admin test user
|
|
29
|
+
*/
|
|
30
|
+
export async function createTestAdmin(payload: Payload): Promise<any> {
|
|
31
|
+
return await createTestUser(payload, {
|
|
32
|
+
email: 'admin@example.com',
|
|
33
|
+
password: 'admin123',
|
|
34
|
+
roles: ['admin'],
|
|
35
|
+
name: 'Admin User',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { sqliteAdapter } from '@payloadcms/db-sqlite';
|
|
2
|
+
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
|
3
|
+
import { Config, getPayload, type Payload } from 'payload';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
import { createEmailTemplatesCollection } from '../../src/collections/EmailTemplates/index.js';
|
|
8
|
+
import { createQueuedEmailsCollection } from '../../src/collections/QueuedEmails/index.js';
|
|
9
|
+
import { createUsersCollection } from '../../src/collections/Users/index.js';
|
|
10
|
+
|
|
11
|
+
const filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const dirname = path.dirname(filename);
|
|
13
|
+
|
|
14
|
+
let cachedPayload: Payload | null = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initialize Payload with SQLite for integration tests
|
|
18
|
+
* Follows PayloadCMS testing pattern
|
|
19
|
+
*/
|
|
20
|
+
export async function initPayloadInt(): Promise<Payload> {
|
|
21
|
+
// Return cached instance if available
|
|
22
|
+
if (cachedPayload) {
|
|
23
|
+
return cachedPayload;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Minimal test configuration
|
|
27
|
+
const config: Config = {
|
|
28
|
+
secret: process.env.PAYLOAD_SECRET || 'test-secret-key',
|
|
29
|
+
db: sqliteAdapter({
|
|
30
|
+
client: {
|
|
31
|
+
url: process.env.DATABASE_URI || ':memory:',
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
editor: lexicalEditor(),
|
|
35
|
+
collections: [createUsersCollection(), createEmailTemplatesCollection(), createQueuedEmailsCollection()],
|
|
36
|
+
globals: [],
|
|
37
|
+
typescript: {
|
|
38
|
+
outputFile: path.resolve(dirname, '../payload-types.ts'),
|
|
39
|
+
},
|
|
40
|
+
onInit: async (payload) => {
|
|
41
|
+
payload.logger.info('Payload initialized for testing');
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const payload = await getPayload({ config });
|
|
46
|
+
|
|
47
|
+
cachedPayload = payload;
|
|
48
|
+
return payload;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cleanup Payload instance
|
|
53
|
+
*/
|
|
54
|
+
export async function cleanupPayload(): Promise<void> {
|
|
55
|
+
if (cachedPayload?.db?.destroy) {
|
|
56
|
+
await cachedPayload.db.destroy();
|
|
57
|
+
}
|
|
58
|
+
cachedPayload = null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
|
|
3
|
+
// Load test environment variables
|
|
4
|
+
config({ path: '.env.test' });
|
|
5
|
+
|
|
6
|
+
// Set test-specific environment variables
|
|
7
|
+
process.env.PAYLOAD_DROP_DATABASE = 'true';
|
|
8
|
+
process.env.PAYLOAD_DISABLE_ADMIN = 'true';
|
|
9
|
+
process.env.NODE_ENV = 'test';
|
package/tests/setup.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { adminAccessOnly } from '@/access/adminAccessOnly';
|
|
3
|
+
|
|
4
|
+
describe('adminAccessOnly access control', () => {
|
|
5
|
+
describe('admin property', () => {
|
|
6
|
+
it('should return true for users with admin role', () => {
|
|
7
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
8
|
+
expect(adminAccessOnly.admin({ req })).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should return false for users without admin role', () => {
|
|
12
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
13
|
+
expect(adminAccessOnly.admin({ req })).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should return false for unauthenticated users', () => {
|
|
17
|
+
const req = { user: null } as any;
|
|
18
|
+
expect(adminAccessOnly.admin({ req })).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('create property', () => {
|
|
23
|
+
it('should return true for admin users', () => {
|
|
24
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
25
|
+
expect(adminAccessOnly.create({ req })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return false for non-admin users', () => {
|
|
29
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
30
|
+
expect(adminAccessOnly.create({ req })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('read property', () => {
|
|
35
|
+
it('should return true for admin users', () => {
|
|
36
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
37
|
+
expect(adminAccessOnly.read({ req })).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return false for non-admin users', () => {
|
|
41
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
42
|
+
expect(adminAccessOnly.read({ req })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('update property', () => {
|
|
47
|
+
it('should return true for admin users', () => {
|
|
48
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
49
|
+
expect(adminAccessOnly.update({ req })).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return false for non-admin users', () => {
|
|
53
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
54
|
+
expect(adminAccessOnly.update({ req })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('delete property', () => {
|
|
59
|
+
it('should return true for admin users', () => {
|
|
60
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
61
|
+
expect(adminAccessOnly.delete({ req })).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return false for non-admin users', () => {
|
|
65
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
66
|
+
expect(adminAccessOnly.delete({ req })).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('readVersions property', () => {
|
|
71
|
+
it('should return true for admin users', () => {
|
|
72
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
73
|
+
expect(adminAccessOnly.readVersions({ req })).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return false for non-admin users', () => {
|
|
77
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
78
|
+
expect(adminAccessOnly.readVersions({ req })).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('unlock property', () => {
|
|
83
|
+
it('should return true for admin users', () => {
|
|
84
|
+
const req = { user: { roles: ['admin'] } } as any;
|
|
85
|
+
expect(adminAccessOnly.unlock({ req })).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return false for non-admin users', () => {
|
|
89
|
+
const req = { user: { roles: ['user'] } } as any;
|
|
90
|
+
expect(adminAccessOnly.unlock({ req })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('all properties with unauthenticated user', () => {
|
|
95
|
+
const req = { user: null } as any;
|
|
96
|
+
|
|
97
|
+
it('should return false for admin', () => {
|
|
98
|
+
expect(adminAccessOnly.admin({ req })).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return false for create', () => {
|
|
102
|
+
expect(adminAccessOnly.create({ req })).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return false for read', () => {
|
|
106
|
+
expect(adminAccessOnly.read({ req })).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should return false for update', () => {
|
|
110
|
+
expect(adminAccessOnly.update({ req })).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return false for delete', () => {
|
|
114
|
+
expect(adminAccessOnly.delete({ req })).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|