@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,115 @@
1
+ 'use client';
2
+
3
+ import { useDocumentInfo, useFormFields } from '@payloadcms/ui';
4
+ import { useState } from 'react';
5
+
6
+ export const RetryEmailButtons: React.FC = () => {
7
+ const { id } = useDocumentInfo();
8
+ const [isRetrying, setIsRetrying] = useState(false);
9
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
10
+
11
+ const attemptCount = useFormFields(([fields]) => fields.attemptCount?.value as number);
12
+ const maxAttempts = useFormFields(([fields]) => fields.maxAttempts?.value as number);
13
+ const status = useFormFields(([fields]) => fields.status?.value as string);
14
+
15
+ // Only show if email has failed and reached max attempts
16
+ if (status !== 'failed' || attemptCount < maxAttempts) {
17
+ return null;
18
+ }
19
+
20
+ const handleRetry = async (additionalAttempts: number) => {
21
+ if (!id) return;
22
+
23
+ setIsRetrying(true);
24
+ setMessage(null);
25
+
26
+ try {
27
+ const response = await fetch(`/api/queued-emails/${id}`, {
28
+ method: 'PATCH',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ },
32
+ body: JSON.stringify({
33
+ maxAttempts: maxAttempts + additionalAttempts,
34
+ status: 'pending', // Reset to pending so job picks it up
35
+ }),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`Failed to update email: ${response.statusText}`);
40
+ }
41
+
42
+ setMessage({
43
+ type: 'success',
44
+ text: `Email réinitialisé avec succès. ${additionalAttempts} tentative(s) supplémentaire(s) ajoutée(s). Le job va réessayer l'envoi.`,
45
+ });
46
+
47
+ // Reload the page after 2 seconds to show updated data
48
+ setTimeout(() => {
49
+ window.location.reload();
50
+ }, 2000);
51
+ } catch (error) {
52
+ setMessage({
53
+ type: 'error',
54
+ text: `Erreur lors de la réinitialisation: ${error instanceof Error ? error.message : 'Unknown error'}`,
55
+ });
56
+ } finally {
57
+ setIsRetrying(false);
58
+ }
59
+ };
60
+
61
+ return (
62
+ <div style={{ marginBottom: '1rem', padding: '1rem', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
63
+ <div style={{ marginBottom: '0.5rem', fontWeight: 600 }}>Réessayer l'envoi de l'email</div>
64
+ <div style={{ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' }}>
65
+ Cet email a atteint le nombre maximum de tentatives ({maxAttempts}). Vous pouvez ajouter des tentatives supplémentaires pour réessayer
66
+ l'envoi.
67
+ </div>
68
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
69
+ <button
70
+ type="button"
71
+ onClick={() => handleRetry(1)}
72
+ disabled={isRetrying}
73
+ style={{
74
+ padding: '0.5rem 1rem',
75
+ backgroundColor: '#0070f3',
76
+ color: 'white',
77
+ border: 'none',
78
+ borderRadius: '4px',
79
+ cursor: isRetrying ? 'not-allowed' : 'pointer',
80
+ opacity: isRetrying ? 0.6 : 1,
81
+ }}>
82
+ {isRetrying ? 'Traitement...' : '+1 tentative'}
83
+ </button>
84
+ <button
85
+ type="button"
86
+ onClick={() => handleRetry(5)}
87
+ disabled={isRetrying}
88
+ style={{
89
+ padding: '0.5rem 1rem',
90
+ backgroundColor: '#0070f3',
91
+ color: 'white',
92
+ border: 'none',
93
+ borderRadius: '4px',
94
+ cursor: isRetrying ? 'not-allowed' : 'pointer',
95
+ opacity: isRetrying ? 0.6 : 1,
96
+ }}>
97
+ {isRetrying ? 'Traitement...' : '+5 tentatives'}
98
+ </button>
99
+ </div>
100
+ {message && (
101
+ <div
102
+ style={{
103
+ marginTop: '1rem',
104
+ padding: '0.75rem',
105
+ backgroundColor: message.type === 'success' ? '#d4edda' : '#f8d7da',
106
+ color: message.type === 'success' ? '#155724' : '#721c24',
107
+ borderRadius: '4px',
108
+ fontSize: '0.9rem',
109
+ }}>
110
+ {message.text}
111
+ </div>
112
+ )}
113
+ </div>
114
+ );
115
+ };
@@ -0,0 +1,246 @@
1
+ import { CollectionConfig } from 'payload';
2
+ import { admins } from '../../access/admins';
3
+
4
+ export const QueuedEmails: CollectionConfig = {
5
+ slug: 'queued-emails',
6
+ labels: {
7
+ singular: { fr: 'Email en file', nl: 'E-mail in wachtrij', en: 'Queued Email' },
8
+ plural: { fr: 'Emails en file', nl: 'E-mails in wachtrij', en: 'Queued Emails' },
9
+ },
10
+ admin: {
11
+ description: { fr: "File d'attente des emails à envoyer", nl: 'Wachtrij van te verzenden e-mails', en: 'Queue of emails to send' },
12
+ group: '✉️ Communication',
13
+ defaultColumns: ['status', 'code', 'recipient', 'createdAt', 'sentAt'],
14
+ hideAPIURL: true,
15
+ },
16
+ access: {
17
+ create: () => false, // Only created programmatically
18
+ delete: admins, // Allow admins to clean up old emails
19
+ read: admins,
20
+ update: admins, // Allow admins to manually retry failed emails
21
+ admin: admins,
22
+ },
23
+ fields: [
24
+ {
25
+ name: 'idempotencyKey',
26
+ label: { fr: "Clé d'idempotence", nl: 'Idempotentiesleutel', en: 'Idempotency Key' },
27
+ type: 'text',
28
+ required: false,
29
+ unique: true,
30
+ index: true,
31
+ admin: {
32
+ readOnly: true,
33
+ description: {
34
+ fr: 'Clé unique pour éviter les doublons',
35
+ nl: 'Unieke sleutel om duplicaten te voorkomen',
36
+ en: 'Unique key to prevent duplicates',
37
+ },
38
+ },
39
+ },
40
+ {
41
+ type: 'row',
42
+ fields: [
43
+ {
44
+ name: 'status',
45
+ label: { fr: 'Statut', nl: 'Status', en: 'Status' },
46
+ type: 'select',
47
+ required: true,
48
+ defaultValue: 'pending',
49
+ options: [
50
+ { label: { fr: 'En attente', nl: 'In afwachting', en: 'Pending' }, value: 'pending' },
51
+ { label: { fr: 'Envoi en cours', nl: 'Verzenden', en: 'Sending' }, value: 'sending' },
52
+ { label: { fr: 'Envoyé', nl: 'Verzonden', en: 'Sent' }, value: 'sent' },
53
+ { label: { fr: 'Échec', nl: 'Mislukt', en: 'Failed' }, value: 'failed' },
54
+ ],
55
+ admin: {
56
+ readOnly: true,
57
+ },
58
+ },
59
+ {
60
+ name: 'code',
61
+ label: { fr: 'Identifiant', nl: 'Identificatie', en: 'Code' },
62
+ type: 'text',
63
+ required: true,
64
+ admin: {
65
+ readOnly: true,
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ {
71
+ name: 'retryActions',
72
+ type: 'ui',
73
+ admin: {
74
+ components: {
75
+ Field: '@latte-macchiat-io/latte-payload/collections/QueuedEmails/components/RetryEmailButtons#RetryEmailButtons',
76
+ },
77
+ condition: (data) => data.status === 'failed' && data.attemptCount >= data.maxAttempts,
78
+ },
79
+ },
80
+ {
81
+ type: 'row',
82
+ fields: [
83
+ {
84
+ name: 'recipient',
85
+ label: { fr: 'Destinataire', nl: 'Ontvanger', en: 'Recipient' },
86
+ type: 'text',
87
+ required: true,
88
+ admin: {
89
+ readOnly: true,
90
+ },
91
+ },
92
+ {
93
+ name: 'cc',
94
+ label: { fr: 'CC', nl: 'CC', en: 'CC' },
95
+ type: 'text',
96
+ required: false,
97
+ admin: {
98
+ readOnly: true,
99
+ },
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ name: 'subject',
105
+ label: { fr: "Sujet de l'email", nl: 'Onderwerp van de e-mail', en: 'Email subject' },
106
+ type: 'text',
107
+ required: true,
108
+ admin: {
109
+ readOnly: true,
110
+ },
111
+ },
112
+ {
113
+ name: 'htmlBody',
114
+ label: { fr: "Contenu de l'email", nl: 'Inhoud van de e-mail', en: 'Email content' },
115
+ type: 'textarea',
116
+ required: true,
117
+ admin: {
118
+ readOnly: true,
119
+ components: {
120
+ Field: '@latte-macchiat-io/latte-payload/collections/QueuedEmails/components/HtmlViewer#HTMLViewer',
121
+ },
122
+ },
123
+ },
124
+ {
125
+ type: 'row',
126
+ fields: [
127
+ {
128
+ name: 'attemptCount',
129
+ label: { fr: 'Nombre de tentatives', nl: 'Aantal pogingen', en: 'Attempt count' },
130
+ type: 'number',
131
+ required: true,
132
+ defaultValue: 0,
133
+ admin: {
134
+ readOnly: true,
135
+ },
136
+ },
137
+ {
138
+ name: 'maxAttempts',
139
+ label: { fr: 'Tentatives maximales', nl: 'Maximaal aantal pogingen', en: 'Max attempts' },
140
+ type: 'number',
141
+ required: true,
142
+ defaultValue: 5,
143
+ admin: {
144
+ readOnly: true,
145
+ },
146
+ },
147
+ ],
148
+ },
149
+ {
150
+ name: 'lastError',
151
+ label: { fr: 'Dernière erreur', nl: 'Laatste fout', en: 'Last error' },
152
+ type: 'textarea',
153
+ required: false,
154
+ admin: {
155
+ readOnly: true,
156
+ condition: (data) => data.status === 'failed',
157
+ },
158
+ },
159
+ {
160
+ name: 'errorHistory',
161
+ label: { fr: 'Historique des erreurs', nl: 'Foutengeschiedenis', en: 'Error history' },
162
+ type: 'array',
163
+ required: false,
164
+ admin: {
165
+ readOnly: true,
166
+ condition: (data) => data.errorHistory && data.errorHistory.length > 0,
167
+ description: {
168
+ fr: 'Liste complète de toutes les erreurs rencontrées',
169
+ nl: 'Volledige lijst van alle fouten',
170
+ en: 'Complete list of all errors encountered',
171
+ },
172
+ },
173
+ fields: [
174
+ {
175
+ name: 'timestamp',
176
+ label: { fr: 'Date', nl: 'Datum', en: 'Timestamp' },
177
+ type: 'date',
178
+ required: true,
179
+ timezone: true,
180
+ admin: {
181
+ readOnly: true,
182
+ date: {
183
+ pickerAppearance: 'dayAndTime',
184
+ timeFormat: 'HH:mm:ss',
185
+ displayFormat: 'dd/MM/yyyy HH:mm:ss',
186
+ },
187
+ },
188
+ },
189
+ {
190
+ name: 'attemptNumber',
191
+ label: { fr: 'Tentative n°', nl: 'Poging nr.', en: 'Attempt #' },
192
+ type: 'number',
193
+ required: true,
194
+ admin: {
195
+ readOnly: true,
196
+ },
197
+ },
198
+ {
199
+ name: 'error',
200
+ label: { fr: "Message d'erreur", nl: 'Foutmelding', en: 'Error message' },
201
+ type: 'textarea',
202
+ required: true,
203
+ admin: {
204
+ readOnly: true,
205
+ },
206
+ },
207
+ ],
208
+ },
209
+ {
210
+ type: 'row',
211
+ fields: [
212
+ {
213
+ name: 'lastAttemptAt',
214
+ label: { fr: 'Dernière tentative', nl: 'Laatste poging', en: 'Last attempt' },
215
+ type: 'date',
216
+ required: false,
217
+ timezone: true,
218
+ admin: {
219
+ readOnly: true,
220
+ date: {
221
+ pickerAppearance: 'dayAndTime',
222
+ timeFormat: 'HH:mm:ss',
223
+ displayFormat: 'dd/MM/yyyy HH:mm:ss',
224
+ },
225
+ },
226
+ },
227
+ {
228
+ name: 'sentAt',
229
+ label: { fr: "Date d'envoi", nl: 'Verzenddatum', en: 'Sent at' },
230
+ type: 'date',
231
+ required: false,
232
+ timezone: true,
233
+ admin: {
234
+ readOnly: true,
235
+ date: {
236
+ pickerAppearance: 'dayAndTime',
237
+ timeFormat: 'HH:mm:ss',
238
+ displayFormat: 'dd/MM/yyyy HH:mm:ss',
239
+ },
240
+ condition: (data) => data.status === 'sent',
241
+ },
242
+ },
243
+ ],
244
+ },
245
+ ],
246
+ };
@@ -0,0 +1,135 @@
1
+ import { TZDate } from '@date-fns/tz';
2
+ import { headers } from 'next/headers';
3
+ import { PayloadRequest, TypedUser } from 'payload';
4
+ import type { EmailTemplateCode, Locale } from '../../../types';
5
+ import { generateEmailHtmlFromTemplate, toDatabaseTZDate } from '../../../utils';
6
+
7
+ const emailCode: EmailTemplateCode = 'password-lost';
8
+
9
+ export interface ForgotPasswordEmailConfig {
10
+ /**
11
+ * Default locale to use if user has no locale set
12
+ * @default 'en'
13
+ */
14
+ defaultLocale?: Locale;
15
+
16
+ /**
17
+ * Function to generate the password reset URL
18
+ * @param token - The reset token
19
+ * @param user - The user object
20
+ * @returns The full password reset URL
21
+ */
22
+ generateResetUrl?: (token: string, user: TypedUser) => Promise<string> | string;
23
+
24
+ /**
25
+ * Timezone for logging email send time
26
+ * @default 'Europe/Brussels'
27
+ */
28
+ timezone?: string;
29
+ }
30
+
31
+ /**
32
+ * Create forgot password email HTML generator with custom configuration
33
+ */
34
+ export function createForgotPasswordEmailGenerator(config?: ForgotPasswordEmailConfig) {
35
+ const defaultLocale = config?.defaultLocale || 'en';
36
+ const timezone = config?.timezone || 'Europe/Brussels';
37
+
38
+ return async function generateForgotPasswordEmailHtmlFromTemplate(args?: {
39
+ req?: PayloadRequest;
40
+ token?: string;
41
+ user?: TypedUser;
42
+ }): Promise<string> {
43
+ if (!args || !args.req || !args.token || !args.user) {
44
+ throw new Error(`generateForgotPasswordEmailHtmlFromTemplate > Missing required parameters`);
45
+ }
46
+
47
+ const payload = args.req.payload;
48
+
49
+ // Generate reset URL
50
+ let resetUrl: string;
51
+ if (config?.generateResetUrl) {
52
+ resetUrl = await config.generateResetUrl(args.token, args.user);
53
+ } else {
54
+ // Default URL generation
55
+ const headersList = await headers();
56
+ const requestDomain = headersList.get('host') || 'localhost:3000';
57
+ const protocol = requestDomain.includes('localhost') ? 'http' : 'https';
58
+ resetUrl = `${protocol}://${requestDomain}/reset-password?token=${args.token}`;
59
+ }
60
+
61
+ const emailContent = await generateEmailHtmlFromTemplate({
62
+ payload: payload,
63
+ emailCode: emailCode,
64
+ locale: args.user.locale || defaultLocale,
65
+ variables: {
66
+ name: args.user.name || '',
67
+ token: args.token,
68
+ url: resetUrl,
69
+ },
70
+ });
71
+
72
+ console.log(`Sending email to ${args.user.email} with subject ${emailContent?.subject}`);
73
+
74
+ // Log the email in queued-emails as 'sent' for tracking (Payload sends this directly)
75
+ try {
76
+ await payload.create({
77
+ collection: 'queued-emails',
78
+ data: {
79
+ status: 'sent',
80
+ code: emailCode,
81
+ recipient: args.user.email || '',
82
+ subject: emailContent?.subject || '',
83
+ htmlBody: emailContent?.body || '',
84
+ attemptCount: 1,
85
+ maxAttempts: 1,
86
+ sentAt: toDatabaseTZDate(TZDate.tz(timezone)),
87
+ // No idempotencyKey to allow multiple password reset emails
88
+ },
89
+ });
90
+ } catch (error) {
91
+ payload.logger.error(`Error logging sent email in queued-emails: ${error}`);
92
+ }
93
+
94
+ return emailContent?.body || `Template ${emailCode} not found`;
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Create forgot password email subject generator with custom configuration
100
+ */
101
+ export function createForgotPasswordEmailSubjectGenerator(config?: ForgotPasswordEmailConfig) {
102
+ const defaultLocale = config?.defaultLocale || 'en';
103
+
104
+ return async function generateForgotPasswordEmailSubjectFromTemplate(args?: {
105
+ req?: PayloadRequest;
106
+ token?: string;
107
+ user?: TypedUser;
108
+ }): Promise<string> {
109
+ if (!args || !args.req || !args.user) {
110
+ throw new Error(`generateForgotPasswordEmailSubjectFromTemplate > Missing required parameters`);
111
+ }
112
+
113
+ const payload = args.req.payload;
114
+
115
+ const emailContentTemplateDocs = await payload.find({
116
+ collection: 'email-templates',
117
+ where: { code: { equals: emailCode } },
118
+ limit: 1,
119
+ locale: args.user.locale || defaultLocale,
120
+ });
121
+
122
+ if (!emailContentTemplateDocs || emailContentTemplateDocs.totalDocs === 0) {
123
+ payload.logger.error(`Email template with code ${emailCode} not found`);
124
+ return `Template "${emailCode}" not found`;
125
+ }
126
+
127
+ return emailContentTemplateDocs.docs[0]?.subject || `Template "${emailCode}" not found`;
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Default forgot password email generators
133
+ */
134
+ export const generateForgotPasswordEmailHtmlFromTemplate = createForgotPasswordEmailGenerator();
135
+ export const generateForgotPasswordEmailSubjectFromTemplate = createForgotPasswordEmailSubjectGenerator();
@@ -0,0 +1,123 @@
1
+ import { TZDate } from '@date-fns/tz';
2
+ import { headers } from 'next/headers';
3
+ import { PayloadRequest, TypedUser } from 'payload';
4
+ import type { EmailTemplateCode, Locale } from '../../../types';
5
+ import { generateEmailHtmlFromTemplate, getEmailTemplateByCode, toDatabaseTZDate } from '../../../utils';
6
+
7
+ const emailCode: EmailTemplateCode = 'verify-email';
8
+
9
+ export interface VerifyEmailConfig {
10
+ /**
11
+ * Default locale to use if user has no locale set
12
+ * @default 'en'
13
+ */
14
+ defaultLocale?: Locale;
15
+
16
+ /**
17
+ * Function to generate the verification URL
18
+ * @param token - The verification token
19
+ * @param user - The user object
20
+ * @returns The full verification URL
21
+ */
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ generateVerifyUrl?: (token: string, user: any) => Promise<string> | string;
24
+
25
+ /**
26
+ * Timezone for logging email send time
27
+ * @default 'Europe/Brussels'
28
+ */
29
+ timezone?: string;
30
+ }
31
+
32
+ /**
33
+ * Create verify email HTML generator with custom configuration
34
+ */
35
+ export function createVerifyEmailGenerator(config?: VerifyEmailConfig) {
36
+ const defaultLocale = config?.defaultLocale || 'en';
37
+ const timezone = config?.timezone || 'Europe/Brussels';
38
+
39
+ return async function generateVerifyEmailHtmlFromTemplate({
40
+ req,
41
+ token,
42
+ user,
43
+ }: {
44
+ req: PayloadRequest;
45
+ token: string;
46
+ user: TypedUser;
47
+ }): Promise<string> {
48
+ const payload = req.payload;
49
+
50
+ // Generate verification URL
51
+ let verifyUrl: string;
52
+ if (config?.generateVerifyUrl) {
53
+ verifyUrl = await config.generateVerifyUrl(token, user);
54
+ } else {
55
+ // Default URL generation
56
+ const headersList = await headers();
57
+ const requestDomain = headersList.get('host') || 'localhost:3000';
58
+ const protocol = requestDomain.includes('localhost') ? 'http' : 'https';
59
+ verifyUrl = `${protocol}://${requestDomain}/verify-email?token=${token}`;
60
+ }
61
+
62
+ const emailContent = await generateEmailHtmlFromTemplate({
63
+ payload: payload,
64
+ emailCode: emailCode,
65
+ locale: user.locale || defaultLocale,
66
+ variables: {
67
+ name: user.name || '',
68
+ token: token,
69
+ url: verifyUrl,
70
+ },
71
+ });
72
+
73
+ payload.logger.debug(`Sending email to ${user.email} with subject ${emailContent?.subject}`);
74
+
75
+ // Log the email in queued-emails as 'sent' for tracking (Payload sends this directly)
76
+ try {
77
+ await payload.create({
78
+ collection: 'queued-emails',
79
+ data: {
80
+ idempotencyKey: `${emailCode}-${user.id}`,
81
+ status: 'sent',
82
+ code: emailCode,
83
+ recipient: user.email || '',
84
+ subject: emailContent?.subject || '',
85
+ htmlBody: emailContent?.body || '',
86
+ attemptCount: 1,
87
+ maxAttempts: 1,
88
+ sentAt: toDatabaseTZDate(TZDate.tz(timezone)),
89
+ },
90
+ });
91
+ } catch (error) {
92
+ payload.logger.error(`Error logging sent email in queued-emails: ${error}`);
93
+ }
94
+
95
+ return emailContent?.body || 'Template "verify-email" not found';
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Create verify email subject generator with custom configuration
101
+ */
102
+ export function createVerifyEmailSubjectGenerator(config?: VerifyEmailConfig) {
103
+ const defaultLocale = config?.defaultLocale || 'en';
104
+
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ return async function generateVerifyEmailSubjectFromTemplate({ req, user }: { req: PayloadRequest; token: string; user: any }): Promise<string> {
107
+ const payload = req.payload;
108
+
109
+ const template = await getEmailTemplateByCode({
110
+ locale: user.locale || defaultLocale,
111
+ code: emailCode,
112
+ payload,
113
+ });
114
+
115
+ return template?.subject || `Template "${emailCode}" not found`;
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Default verify email generators
121
+ */
122
+ export const generateVerifyEmailHtmlFromTemplate = createVerifyEmailGenerator();
123
+ export const generateVerifyEmailSubjectFromTemplate = createVerifyEmailSubjectGenerator();
@@ -0,0 +1,22 @@
1
+ import type { FieldHook } from 'payload';
2
+
3
+ // ensure the first user created is an admin
4
+ // 1. lookup a single user on create as succinctly as possible
5
+ // 2. if there are no users found, append `admin` to the roles array
6
+ // access control is already handled by this fields `access` property
7
+ // it ensures that only admins can create and update the `roles` field
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ export const ensureFirstUserIsAdmin: FieldHook<any> = async ({ req, operation, value }) => {
11
+ if (operation === 'create') {
12
+ const users = await req.payload.find({ collection: 'users', limit: 0, depth: 0 });
13
+ if (users.totalDocs === 0) {
14
+ // if `admin` not in array of values, add it
15
+ if (!(value || []).includes('admin')) {
16
+ return [...(value || []), 'admin'];
17
+ }
18
+ }
19
+ }
20
+
21
+ return value;
22
+ };