@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.
Files changed (253) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.env.example +29 -0
  3. package/.github/workflows/ci.yml +160 -0
  4. package/.github/workflows/publish.yml +126 -0
  5. package/.nvmrc +1 -0
  6. package/.prettierignore +2 -0
  7. package/.prettierrc +11 -0
  8. package/CHANGELOG.md +87 -0
  9. package/README.md +364 -0
  10. package/TESTING_AND_DOCUMENTATION_SETUP.md +348 -0
  11. package/dist/access/adminAccessOnly.d.ts +25 -0
  12. package/dist/access/adminAccessOnly.d.ts.map +1 -0
  13. package/dist/access/adminAccessOnly.js +11 -0
  14. package/dist/access/adminAccessOnly.js.map +1 -0
  15. package/dist/access/admins.d.ts +72 -0
  16. package/dist/access/admins.d.ts.map +1 -0
  17. package/dist/access/admins.js +76 -0
  18. package/dist/access/admins.js.map +1 -0
  19. package/dist/access/anyone.d.ts +35 -0
  20. package/dist/access/anyone.d.ts.map +1 -0
  21. package/dist/access/anyone.js +34 -0
  22. package/dist/access/anyone.js.map +1 -0
  23. package/dist/access/authenticated.d.ts +63 -0
  24. package/dist/access/authenticated.d.ts.map +1 -0
  25. package/dist/access/authenticated.js +68 -0
  26. package/dist/access/authenticated.js.map +1 -0
  27. package/dist/access/authenticatedAccessOnly.d.ts +13 -0
  28. package/dist/access/authenticatedAccessOnly.d.ts.map +1 -0
  29. package/dist/access/authenticatedAccessOnly.js +11 -0
  30. package/dist/access/authenticatedAccessOnly.js.map +1 -0
  31. package/dist/access/index.d.ts +7 -0
  32. package/dist/access/index.d.ts.map +1 -0
  33. package/dist/access/index.js +7 -0
  34. package/dist/access/index.js.map +1 -0
  35. package/dist/access/publicReadAuthenticatedAccess.d.ts +13 -0
  36. package/dist/access/publicReadAuthenticatedAccess.d.ts.map +1 -0
  37. package/dist/access/publicReadAuthenticatedAccess.js +12 -0
  38. package/dist/access/publicReadAuthenticatedAccess.js.map +1 -0
  39. package/dist/collections/ContactMessages/hooks/sendEmailNotification.d.ts +31 -0
  40. package/dist/collections/ContactMessages/hooks/sendEmailNotification.d.ts.map +1 -0
  41. package/dist/collections/ContactMessages/hooks/sendEmailNotification.js +29 -0
  42. package/dist/collections/ContactMessages/hooks/sendEmailNotification.js.map +1 -0
  43. package/dist/collections/ContactMessages/index.d.ts +27 -0
  44. package/dist/collections/ContactMessages/index.d.ts.map +1 -0
  45. package/dist/collections/ContactMessages/index.js +81 -0
  46. package/dist/collections/ContactMessages/index.js.map +1 -0
  47. package/dist/collections/EmailTemplates/index.d.ts +26 -0
  48. package/dist/collections/EmailTemplates/index.d.ts.map +1 -0
  49. package/dist/collections/EmailTemplates/index.js +74 -0
  50. package/dist/collections/EmailTemplates/index.js.map +1 -0
  51. package/dist/collections/Media/index.d.ts +3 -0
  52. package/dist/collections/Media/index.d.ts.map +1 -0
  53. package/dist/collections/Media/index.js +22 -0
  54. package/dist/collections/Media/index.js.map +1 -0
  55. package/dist/collections/QueuedEmails/components/HtmlViewer.d.ts +3 -0
  56. package/dist/collections/QueuedEmails/components/HtmlViewer.d.ts.map +1 -0
  57. package/dist/collections/QueuedEmails/components/HtmlViewer.js +11 -0
  58. package/dist/collections/QueuedEmails/components/HtmlViewer.js.map +1 -0
  59. package/dist/collections/QueuedEmails/components/RetryEmailButtons.d.ts +2 -0
  60. package/dist/collections/QueuedEmails/components/RetryEmailButtons.d.ts.map +1 -0
  61. package/dist/collections/QueuedEmails/components/RetryEmailButtons.js +79 -0
  62. package/dist/collections/QueuedEmails/components/RetryEmailButtons.js.map +1 -0
  63. package/dist/collections/QueuedEmails/index.d.ts +3 -0
  64. package/dist/collections/QueuedEmails/index.d.ts.map +1 -0
  65. package/dist/collections/QueuedEmails/index.js +245 -0
  66. package/dist/collections/QueuedEmails/index.js.map +1 -0
  67. package/dist/collections/Users/auth-emails/forgot-password-email.d.ts +51 -0
  68. package/dist/collections/Users/auth-emails/forgot-password-email.d.ts.map +1 -0
  69. package/dist/collections/Users/auth-emails/forgot-password-email.js +90 -0
  70. package/dist/collections/Users/auth-emails/forgot-password-email.js.map +1 -0
  71. package/dist/collections/Users/auth-emails/verify-email.d.ts +51 -0
  72. package/dist/collections/Users/auth-emails/verify-email.d.ts.map +1 -0
  73. package/dist/collections/Users/auth-emails/verify-email.js +80 -0
  74. package/dist/collections/Users/auth-emails/verify-email.js.map +1 -0
  75. package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.d.ts +3 -0
  76. package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.d.ts.map +1 -0
  77. package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.js +19 -0
  78. package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.js.map +1 -0
  79. package/dist/collections/Users/index.d.ts +76 -0
  80. package/dist/collections/Users/index.d.ts.map +1 -0
  81. package/dist/collections/Users/index.js +116 -0
  82. package/dist/collections/Users/index.js.map +1 -0
  83. package/dist/collections/index.d.ts +10 -0
  84. package/dist/collections/index.d.ts.map +1 -0
  85. package/dist/collections/index.js +14 -0
  86. package/dist/collections/index.js.map +1 -0
  87. package/dist/components/index.d.ts +3 -0
  88. package/dist/components/index.d.ts.map +1 -0
  89. package/dist/components/index.js +4 -0
  90. package/dist/components/index.js.map +1 -0
  91. package/dist/forms/states.d.ts +8 -0
  92. package/dist/forms/states.d.ts.map +1 -0
  93. package/dist/forms/states.js +16 -0
  94. package/dist/forms/states.js.map +1 -0
  95. package/dist/forms/translate-errors.d.ts +8 -0
  96. package/dist/forms/translate-errors.d.ts.map +1 -0
  97. package/dist/forms/translate-errors.js +11 -0
  98. package/dist/forms/translate-errors.js.map +1 -0
  99. package/dist/forms/validators.d.ts +10 -0
  100. package/dist/forms/validators.d.ts.map +1 -0
  101. package/dist/forms/validators.js +23 -0
  102. package/dist/forms/validators.js.map +1 -0
  103. package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.d.ts +2 -0
  104. package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.d.ts.map +1 -0
  105. package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.js +5 -0
  106. package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.js.map +1 -0
  107. package/dist/globals/PrivacyPolicy/index.d.ts +3 -0
  108. package/dist/globals/PrivacyPolicy/index.d.ts.map +1 -0
  109. package/dist/globals/PrivacyPolicy/index.js +31 -0
  110. package/dist/globals/PrivacyPolicy/index.js.map +1 -0
  111. package/dist/globals/TermsOfUse/hooks/revalidate-cache.d.ts +2 -0
  112. package/dist/globals/TermsOfUse/hooks/revalidate-cache.d.ts.map +1 -0
  113. package/dist/globals/TermsOfUse/hooks/revalidate-cache.js +5 -0
  114. package/dist/globals/TermsOfUse/hooks/revalidate-cache.js.map +1 -0
  115. package/dist/globals/TermsOfUse/index.d.ts +3 -0
  116. package/dist/globals/TermsOfUse/index.d.ts.map +1 -0
  117. package/dist/globals/TermsOfUse/index.js +31 -0
  118. package/dist/globals/TermsOfUse/index.js.map +1 -0
  119. package/dist/globals/index.d.ts +3 -0
  120. package/dist/globals/index.d.ts.map +1 -0
  121. package/dist/globals/index.js +3 -0
  122. package/dist/globals/index.js.map +1 -0
  123. package/dist/index.d.ts +13 -0
  124. package/dist/index.d.ts.map +1 -0
  125. package/dist/index.js +20 -0
  126. package/dist/index.js.map +1 -0
  127. package/dist/tasks/index.d.ts +2 -0
  128. package/dist/tasks/index.d.ts.map +1 -0
  129. package/dist/tasks/index.js +2 -0
  130. package/dist/tasks/index.js.map +1 -0
  131. package/dist/tasks/process-email-queue.d.ts +46 -0
  132. package/dist/tasks/process-email-queue.d.ts.map +1 -0
  133. package/dist/tasks/process-email-queue.js +199 -0
  134. package/dist/tasks/process-email-queue.js.map +1 -0
  135. package/dist/types/index.d.ts +14 -0
  136. package/dist/types/index.d.ts.map +1 -0
  137. package/dist/types/index.js +2 -0
  138. package/dist/types/index.js.map +1 -0
  139. package/dist/types/slug.d.ts +2 -0
  140. package/dist/types/slug.d.ts.map +1 -0
  141. package/dist/types/slug.js +11 -0
  142. package/dist/types/slug.js.map +1 -0
  143. package/dist/utils/database-dates.d.ts +3 -0
  144. package/dist/utils/database-dates.d.ts.map +1 -0
  145. package/dist/utils/database-dates.js +6 -0
  146. package/dist/utils/database-dates.js.map +1 -0
  147. package/dist/utils/email/generate-email-html.d.ts +23 -0
  148. package/dist/utils/email/generate-email-html.d.ts.map +1 -0
  149. package/dist/utils/email/generate-email-html.js +42 -0
  150. package/dist/utils/email/generate-email-html.js.map +1 -0
  151. package/dist/utils/email/get-email-template.d.ts +8 -0
  152. package/dist/utils/email/get-email-template.d.ts.map +1 -0
  153. package/dist/utils/email/get-email-template.js +14 -0
  154. package/dist/utils/email/get-email-template.js.map +1 -0
  155. package/dist/utils/email/index.d.ts +4 -0
  156. package/dist/utils/email/index.d.ts.map +1 -0
  157. package/dist/utils/email/index.js +4 -0
  158. package/dist/utils/email/index.js.map +1 -0
  159. package/dist/utils/email/queue-email.d.ts +29 -0
  160. package/dist/utils/email/queue-email.d.ts.map +1 -0
  161. package/dist/utils/email/queue-email.js +79 -0
  162. package/dist/utils/email/queue-email.js.map +1 -0
  163. package/dist/utils/get-global.d.ts +6 -0
  164. package/dist/utils/get-global.d.ts.map +1 -0
  165. package/dist/utils/get-global.js +20 -0
  166. package/dist/utils/get-global.js.map +1 -0
  167. package/dist/utils/id-from-payload.d.ts +4 -0
  168. package/dist/utils/id-from-payload.d.ts.map +1 -0
  169. package/dist/utils/id-from-payload.js +10 -0
  170. package/dist/utils/id-from-payload.js.map +1 -0
  171. package/dist/utils/index.d.ts +4 -0
  172. package/dist/utils/index.d.ts.map +1 -0
  173. package/dist/utils/index.js +4 -0
  174. package/dist/utils/index.js.map +1 -0
  175. package/dist/utils/migrations.d.ts +2 -0
  176. package/dist/utils/migrations.d.ts.map +1 -0
  177. package/dist/utils/migrations.js +18 -0
  178. package/dist/utils/migrations.js.map +1 -0
  179. package/dist/utils/payload-client.d.ts +10 -0
  180. package/dist/utils/payload-client.d.ts.map +1 -0
  181. package/dist/utils/payload-client.js +14 -0
  182. package/dist/utils/payload-client.js.map +1 -0
  183. package/dist/utils/slugify.d.ts +8 -0
  184. package/dist/utils/slugify.d.ts.map +1 -0
  185. package/dist/utils/slugify.js +15 -0
  186. package/dist/utils/slugify.js.map +1 -0
  187. package/eslint.config.mjs +90 -0
  188. package/package.json +139 -0
  189. package/pnpm-workspace.yaml +4 -0
  190. package/src/access/adminAccessOnly.ts +13 -0
  191. package/src/access/admins.ts +78 -0
  192. package/src/access/anyone.ts +35 -0
  193. package/src/access/authenticated.ts +70 -0
  194. package/src/access/authenticatedAccessOnly.ts +13 -0
  195. package/src/access/index.ts +6 -0
  196. package/src/access/publicReadAuthenticatedAccess.ts +14 -0
  197. package/src/collections/ContactMessages/hooks/sendEmailNotification.ts +58 -0
  198. package/src/collections/ContactMessages/index.ts +100 -0
  199. package/src/collections/EmailTemplates/index.ts +89 -0
  200. package/src/collections/Media/index.ts +24 -0
  201. package/src/collections/QueuedEmails/components/HtmlViewer.tsx +16 -0
  202. package/src/collections/QueuedEmails/components/RetryEmailButtons.tsx +115 -0
  203. package/src/collections/QueuedEmails/index.ts +246 -0
  204. package/src/collections/Users/auth-emails/forgot-password-email.ts +135 -0
  205. package/src/collections/Users/auth-emails/verify-email.ts +123 -0
  206. package/src/collections/Users/hooks/ensureFirstUserIsAdmin.ts +22 -0
  207. package/src/collections/Users/index.ts +201 -0
  208. package/src/collections/index.ts +23 -0
  209. package/src/components/index.ts +3 -0
  210. package/src/forms/states.ts +23 -0
  211. package/src/forms/translate-errors.ts +13 -0
  212. package/src/forms/validators.ts +33 -0
  213. package/src/globals/PrivacyPolicy/hooks/revalidate-cache.ts +5 -0
  214. package/src/globals/PrivacyPolicy/index.ts +33 -0
  215. package/src/globals/TermsOfUse/hooks/revalidate-cache.ts +5 -0
  216. package/src/globals/TermsOfUse/index.ts +33 -0
  217. package/src/globals/index.ts +2 -0
  218. package/src/index.ts +26 -0
  219. package/src/tasks/index.ts +7 -0
  220. package/src/tasks/process-email-queue.ts +261 -0
  221. package/src/types/index.ts +15 -0
  222. package/src/types/slug.ts +11 -0
  223. package/src/utils/database-dates.ts +6 -0
  224. package/src/utils/email/generate-email-html.ts +63 -0
  225. package/src/utils/email/get-email-template.ts +18 -0
  226. package/src/utils/email/index.ts +3 -0
  227. package/src/utils/email/queue-email.ts +109 -0
  228. package/src/utils/get-global.ts +25 -0
  229. package/src/utils/id-from-payload.ts +11 -0
  230. package/src/utils/index.ts +3 -0
  231. package/src/utils/migrations.ts +18 -0
  232. package/src/utils/payload-client.ts +16 -0
  233. package/src/utils/slugify.ts +21 -0
  234. package/tests/fixtures/email-template.html +58 -0
  235. package/tests/fixtures/sample-data.ts +56 -0
  236. package/tests/helpers/create-test-user.ts +37 -0
  237. package/tests/helpers/init-payload.ts +59 -0
  238. package/tests/setup.integration.ts +9 -0
  239. package/tests/setup.ts +4 -0
  240. package/tests/unit/access/adminAccessOnly.spec.ts +117 -0
  241. package/tests/unit/access/admins.spec.ts +68 -0
  242. package/tests/unit/access/anyone.spec.ts +28 -0
  243. package/tests/unit/access/authenticated.spec.ts +53 -0
  244. package/tests/unit/access/authenticatedAccessOnly.spec.ts +112 -0
  245. package/tests/unit/access/publicReadAuthenticatedAccess.spec.ts +112 -0
  246. package/tests/unit/forms/validators.spec.ts +348 -0
  247. package/tests/unit/utils/database-dates.spec.ts +97 -0
  248. package/tests/unit/utils/id-from-payload.spec.ts +142 -0
  249. package/tests/unit/utils/slugify.spec.ts +185 -0
  250. package/tsconfig.json +31 -0
  251. package/typedoc.json +40 -0
  252. package/vitest.config.ts +31 -0
  253. 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,6 @@
1
+ import { TZDate } from '@date-fns/tz';
2
+
3
+ export function toDatabaseTZDate(date: TZDate | string): string {
4
+ if (typeof date === 'string') return date;
5
+ return date.toISOString();
6
+ }
@@ -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,3 @@
1
+ export { getEmailTemplateByCode } from './get-email-template';
2
+ export { generateEmailHtmlFromTemplate } from './generate-email-html';
3
+ export { queueEmail } from './queue-email';
@@ -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,3 @@
1
+ export * from './email';
2
+ export { toDatabaseTZDate } from './database-dates';
3
+ export { getPayloadClient } from './payload-client';
@@ -0,0 +1,18 @@
1
+ export const toRichText = (text: string | null) =>
2
+ text
3
+ ? JSON.stringify({
4
+ root: {
5
+ type: 'root',
6
+ children: [
7
+ {
8
+ type: 'paragraph',
9
+ children: [
10
+ {
11
+ text,
12
+ },
13
+ ],
14
+ },
15
+ ],
16
+ },
17
+ })
18
+ : null;
@@ -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>&copy; {{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,4 @@
1
+ import { expect } from 'vitest';
2
+
3
+ // Export vitest globals for test files
4
+ export { expect };
@@ -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
+ });