@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,201 @@
1
+ import type { CollectionConfig } from 'payload';
2
+
3
+ import {
4
+ createForgotPasswordEmailGenerator,
5
+ createForgotPasswordEmailSubjectGenerator,
6
+ type ForgotPasswordEmailConfig,
7
+ generateForgotPasswordEmailHtmlFromTemplate,
8
+ generateForgotPasswordEmailSubjectFromTemplate,
9
+ } from './auth-emails/forgot-password-email';
10
+ import {
11
+ createVerifyEmailGenerator,
12
+ createVerifyEmailSubjectGenerator,
13
+ generateVerifyEmailHtmlFromTemplate,
14
+ generateVerifyEmailSubjectFromTemplate,
15
+ type VerifyEmailConfig,
16
+ } from './auth-emails/verify-email';
17
+ import { ensureFirstUserIsAdmin } from './hooks/ensureFirstUserIsAdmin';
18
+ import { adminsFieldLevel } from '../../access/admins';
19
+ import { authenticatedAccessOnly } from '../../access/authenticatedAccessOnly';
20
+ import type { Locale } from '../../types';
21
+
22
+ export interface UsersCollectionConfig {
23
+ /**
24
+ * User roles
25
+ * @default ['admin', 'user']
26
+ */
27
+ roles?: Array<{ label: { fr: string; nl: string; en: string }; value: string }>;
28
+
29
+ /**
30
+ * Available locales for users
31
+ * @default [{ label: { fr: 'Anglais', nl: 'Engels', en: 'English' }, value: 'en' }, ...]
32
+ */
33
+ locales?: Array<{ label: { fr: string; nl: string; en: string }; value: Locale }>;
34
+
35
+ /**
36
+ * Default locale
37
+ * @default 'en'
38
+ */
39
+ defaultLocale?: Locale;
40
+
41
+ /**
42
+ * Token expiration in seconds
43
+ * @default 60 * 60 * 24 * 365 (1 year)
44
+ */
45
+ tokenExpiration?: number;
46
+
47
+ /**
48
+ * Maximum login attempts before lockout
49
+ * @default 10
50
+ */
51
+ maxLoginAttempts?: number;
52
+
53
+ /**
54
+ * Lockout time in milliseconds
55
+ * @default 1000 * 60 * 10 (10 minutes)
56
+ */
57
+ lockTime?: number;
58
+
59
+ /**
60
+ * Forgot password expiration in milliseconds
61
+ * @default 1000 * 60 * 60 * 24 * 10 (10 days)
62
+ */
63
+ forgotPasswordExpiration?: number;
64
+
65
+ /**
66
+ * Configuration for verify email
67
+ */
68
+ verifyEmailConfig?: VerifyEmailConfig;
69
+
70
+ /**
71
+ * Configuration for forgot password email
72
+ */
73
+ forgotPasswordEmailConfig?: ForgotPasswordEmailConfig;
74
+ }
75
+
76
+ /**
77
+ * Create Users collection with optional configuration
78
+ *
79
+ * @param config - Optional configuration for the Users collection
80
+ * @returns CollectionConfig for Users
81
+ */
82
+ export function createUsersCollection(config?: UsersCollectionConfig): CollectionConfig {
83
+ const roles = config?.roles || [
84
+ {
85
+ label: { fr: 'Administrateur', nl: 'Beheerder', en: 'Admin' },
86
+ value: 'admin',
87
+ },
88
+ {
89
+ label: { fr: 'User', nl: 'Gebruiker', en: 'User' },
90
+ value: 'user',
91
+ },
92
+ ];
93
+
94
+ const locales = config?.locales || [
95
+ {
96
+ label: { fr: 'Anglais', nl: 'Engels', en: 'English' },
97
+ value: 'en' as Locale,
98
+ },
99
+ {
100
+ label: { fr: 'Français', nl: 'Frans', en: 'French' },
101
+ value: 'fr' as Locale,
102
+ },
103
+ {
104
+ label: { fr: 'Néerlandais', nl: 'Nederlands', en: 'Dutch' },
105
+ value: 'nl' as Locale,
106
+ },
107
+ ];
108
+
109
+ return {
110
+ slug: 'users',
111
+ labels: {
112
+ singular: { fr: 'Utilisateur', en: 'User', nl: 'Gebruiker' },
113
+ plural: { fr: 'Utilisateurs', en: 'Users', nl: 'Gebruikers' },
114
+ },
115
+ admin: {
116
+ useAsTitle: 'email',
117
+ hideAPIURL: true,
118
+ group: { fr: 'Utilisateurs', nl: 'Gebruikers', en: 'Users' },
119
+ },
120
+ access: authenticatedAccessOnly,
121
+ auth: {
122
+ tokenExpiration: config?.tokenExpiration ?? 60 * 60 * 24 * 365, // In seconds, 1 year
123
+ maxLoginAttempts: config?.maxLoginAttempts ?? 10,
124
+ lockTime: config?.lockTime ?? 1000 * 60 * 10, // In milliseconds, 10 minutes
125
+
126
+ verify: {
127
+ generateEmailHTML: config?.verifyEmailConfig ? createVerifyEmailGenerator(config.verifyEmailConfig) : generateVerifyEmailHtmlFromTemplate,
128
+ generateEmailSubject: config?.verifyEmailConfig
129
+ ? createVerifyEmailSubjectGenerator(config.verifyEmailConfig)
130
+ : generateVerifyEmailSubjectFromTemplate,
131
+ },
132
+ forgotPassword: {
133
+ expiration: config?.forgotPasswordExpiration ?? 1000 * 60 * 60 * 24 * 10, // In milliseconds, 10 days
134
+ generateEmailHTML: config?.forgotPasswordEmailConfig
135
+ ? createForgotPasswordEmailGenerator(config.forgotPasswordEmailConfig)
136
+ : generateForgotPasswordEmailHtmlFromTemplate,
137
+ generateEmailSubject: config?.forgotPasswordEmailConfig
138
+ ? createForgotPasswordEmailSubjectGenerator(config.forgotPasswordEmailConfig)
139
+ : generateForgotPasswordEmailSubjectFromTemplate,
140
+ },
141
+ },
142
+
143
+ fields: [
144
+ // Email added by default
145
+ // Add more fields as needed
146
+
147
+ {
148
+ type: 'row',
149
+ fields: [
150
+ {
151
+ name: 'name',
152
+ label: { fr: 'Nom', nl: 'Naam', en: 'Name' },
153
+ type: 'text',
154
+ required: false,
155
+ },
156
+
157
+ {
158
+ name: 'locale',
159
+ label: { fr: 'Langue', nl: 'Taal', en: 'Language' },
160
+ type: 'select',
161
+ hasMany: false,
162
+ defaultValue: config?.defaultLocale || 'en',
163
+ options: locales,
164
+ },
165
+ ],
166
+ },
167
+
168
+ {
169
+ name: 'roles',
170
+ label: { fr: 'Rôles', nl: 'Rollen', en: 'Roles' },
171
+ type: 'select',
172
+ hasMany: true,
173
+ defaultValue: ['user'],
174
+ options: roles,
175
+ hooks: {
176
+ beforeChange: [ensureFirstUserIsAdmin],
177
+ },
178
+ access: {
179
+ read: adminsFieldLevel,
180
+ create: adminsFieldLevel,
181
+ update: adminsFieldLevel,
182
+ },
183
+ },
184
+ ],
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Default Users collection
190
+ */
191
+ export const Users = createUsersCollection();
192
+
193
+ // Re-export auth email generators for customization
194
+ export {
195
+ createVerifyEmailGenerator,
196
+ createVerifyEmailSubjectGenerator,
197
+ createForgotPasswordEmailGenerator,
198
+ createForgotPasswordEmailSubjectGenerator,
199
+ type VerifyEmailConfig,
200
+ type ForgotPasswordEmailConfig,
201
+ };
@@ -0,0 +1,23 @@
1
+ // Collections
2
+ export { Users, createUsersCollection, type UsersCollectionConfig } from './Users';
3
+ export { Media } from './Media';
4
+ export { ContactMessages, createContactMessagesCollection, type ContactMessagesConfig } from './ContactMessages';
5
+ export { EmailTemplates, createEmailTemplatesCollection, type EmailTemplatesConfig } from './EmailTemplates';
6
+ export { QueuedEmails } from './QueuedEmails';
7
+
8
+ // Users auth emails
9
+ export {
10
+ createVerifyEmailGenerator,
11
+ createVerifyEmailSubjectGenerator,
12
+ createForgotPasswordEmailGenerator,
13
+ createForgotPasswordEmailSubjectGenerator,
14
+ type VerifyEmailConfig,
15
+ type ForgotPasswordEmailConfig,
16
+ } from './Users';
17
+
18
+ // ContactMessages hooks
19
+ export { createSendEmailNotificationHook, type SendEmailNotificationConfig } from './ContactMessages';
20
+
21
+ // QueuedEmails components
22
+ export { HTMLViewer } from './QueuedEmails/components/HtmlViewer';
23
+ export { RetryEmailButtons } from './QueuedEmails/components/RetryEmailButtons';
@@ -0,0 +1,3 @@
1
+ // Re-export components from collections for backward compatibility
2
+ export { HTMLViewer } from '../collections/QueuedEmails/components/HtmlViewer';
3
+ export { RetryEmailButtons } from '../collections/QueuedEmails/components/RetryEmailButtons';
@@ -0,0 +1,23 @@
1
+ export function createSuccessFormState<T>(previousState: T): { success: true } & T {
2
+ return {
3
+ ...previousState,
4
+ success: true,
5
+ };
6
+ }
7
+
8
+ // TODO createErrorFormState
9
+
10
+ export function extractFormDataToReturn<T extends Record<string, unknown>>({
11
+ formData,
12
+ excludes,
13
+ }: {
14
+ formData: FormData;
15
+ excludes?: (keyof T)[];
16
+ }): T {
17
+ const data = Object.fromEntries(formData.entries()) as T;
18
+ excludes?.forEach((field) => {
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ (data as any)[field] = '';
21
+ });
22
+ return data;
23
+ }
@@ -0,0 +1,13 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { z } from 'zod';
3
+
4
+ // Utility to translate error keys using i18n
5
+ export async function translateFormErrors<T extends z.ZodTypeAny>(errors: z.inferFlattenedErrors<T>) {
6
+ const t = await getTranslations();
7
+ const translate = (messages?: string[]) => messages?.map((msg) => t(msg)) ?? [];
8
+
9
+ return {
10
+ fieldErrors: Object.fromEntries(Object.entries(errors.fieldErrors).map(([field, msgs]) => [field, translate(msgs as string[])])),
11
+ formErrors: translate(errors.formErrors),
12
+ };
13
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+
3
+ export const requiredString = (message: string) =>
4
+ z
5
+ .string({ errorMap: () => ({ message }) }) // Same error message for all errors
6
+ .trim()
7
+ .nonempty();
8
+
9
+ export const requiredPasswordString = (minLength: number, requiredMessage: string, minLengthMessage: string) =>
10
+ z
11
+ .string({ errorMap: () => ({ message: requiredMessage }) }) // Same error message for all errors
12
+ .trim()
13
+ .nonempty()
14
+ .pipe(
15
+ z.string().min(minLength, { message: minLengthMessage }) // Ensure password is at least 12 characters long
16
+ );
17
+
18
+ export const requiredEmailString = ({ required, email }: { required: string; email: string }) =>
19
+ z
20
+ .string() // Same error message for all errors
21
+ .trim()
22
+ .nonempty({ message: required })
23
+ .pipe(z.string().email({ message: email })); // “email” runs only if nonempty passed
24
+
25
+ export const validHoneyPot = (message: string) =>
26
+ z
27
+ .string({ errorMap: () => ({ message }) }) // Same error message for all errors
28
+ .refine((val) => val.length === 0); // Must be empty
29
+
30
+ export const requiredCheckbox = (message: string) =>
31
+ z
32
+ .boolean({ errorMap: () => ({ message }) }) // Same error message for all errors
33
+ .refine((val) => val); // Must be true
@@ -0,0 +1,5 @@
1
+ import { revalidatePath } from 'next/cache';
2
+
3
+ export const revalidateCache = async () => {
4
+ revalidatePath('/', 'layout');
5
+ };
@@ -0,0 +1,33 @@
1
+ import type { GlobalConfig } from 'payload';
2
+
3
+ import { revalidateCache } from './hooks/revalidate-cache';
4
+ import { authenticatedAccessOnly } from '../../access/authenticatedAccessOnly';
5
+
6
+ export const PrivacyPolicy: GlobalConfig = {
7
+ slug: 'privacy-policy',
8
+ label: { fr: 'Politique de confidentialité', nl: 'Privacybeleid', en: 'Privacy Policy' },
9
+ admin: {
10
+ group: { fr: 'Légal', nl: 'Juridisch', en: 'Legal' },
11
+ hideAPIURL: true,
12
+ },
13
+ access: authenticatedAccessOnly,
14
+ hooks: {
15
+ afterChange: [revalidateCache],
16
+ },
17
+ fields: [
18
+ {
19
+ name: 'title',
20
+ label: { fr: 'Titre', nl: 'Titel', en: 'Title' },
21
+ type: 'text',
22
+ required: true,
23
+ localized: true,
24
+ },
25
+ {
26
+ name: 'content',
27
+ label: { fr: 'Contenu', nl: 'Inhoud', en: 'Content' },
28
+ type: 'richText',
29
+ required: true,
30
+ localized: true,
31
+ },
32
+ ],
33
+ };
@@ -0,0 +1,5 @@
1
+ import { revalidatePath } from 'next/cache';
2
+
3
+ export const revalidateCache = async () => {
4
+ revalidatePath('/', 'layout');
5
+ };
@@ -0,0 +1,33 @@
1
+ import type { GlobalConfig } from 'payload';
2
+
3
+ import { revalidateCache } from './hooks/revalidate-cache';
4
+ import { authenticatedAccessOnly } from '../../access/authenticatedAccessOnly';
5
+
6
+ export const TermsOfUse: GlobalConfig = {
7
+ slug: 'terms-of-use',
8
+ label: { fr: "Conditions d'utilisation", nl: 'Gebruiksvoorwaarden', en: 'Terms of Use' },
9
+ admin: {
10
+ group: { fr: 'Légal', nl: 'Juridisch', en: 'Legal' },
11
+ hideAPIURL: true,
12
+ },
13
+ access: authenticatedAccessOnly,
14
+ hooks: {
15
+ afterChange: [revalidateCache],
16
+ },
17
+ fields: [
18
+ {
19
+ name: 'title',
20
+ label: { fr: 'Titre', nl: 'Titel', en: 'Title' },
21
+ type: 'text',
22
+ required: true,
23
+ localized: true,
24
+ },
25
+ {
26
+ name: 'content',
27
+ label: { fr: 'Contenu', nl: 'Inhoud', en: 'Content' },
28
+ type: 'richText',
29
+ required: true,
30
+ localized: true,
31
+ },
32
+ ],
33
+ };
@@ -0,0 +1,2 @@
1
+ export { PrivacyPolicy } from './PrivacyPolicy';
2
+ export { TermsOfUse } from './TermsOfUse';
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @latte-macchiat-io/latte-payload
3
+ *
4
+ * Reusable Payload CMS collections, utilities, and components for Latte projects
5
+ */
6
+
7
+ // Collections
8
+ export * from './collections';
9
+
10
+ // Globals
11
+ export * from './globals';
12
+
13
+ // Access Control
14
+ export * from './access';
15
+
16
+ // Tasks
17
+ export * from './tasks';
18
+
19
+ // Utilities
20
+ export * from './utils';
21
+
22
+ // Components
23
+ export * from './components';
24
+
25
+ // Types
26
+ export * from './types';
@@ -0,0 +1,7 @@
1
+ export {
2
+ processEmailQueueTaskConfig,
3
+ createProcessEmailQueueTask,
4
+ processEmailQueueName,
5
+ processEmailQueueAutoRunConfig,
6
+ type ProcessEmailQueueConfig,
7
+ } from './process-email-queue';
@@ -0,0 +1,261 @@
1
+ import { TZDate } from '@date-fns/tz';
2
+ import { TaskConfig } from 'payload';
3
+ import { toDatabaseTZDate } from '../utils';
4
+
5
+ export const processEmailQueueName = 'process-email-queue';
6
+
7
+ export const processEmailQueueAutoRunConfig = {
8
+ queue: processEmailQueueName,
9
+ cron: '* * * * *', // Every minute
10
+ };
11
+
12
+ // Utility function to add delay between emails to respect rate limits
13
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
14
+
15
+ export interface ProcessEmailQueueConfig {
16
+ /**
17
+ * Timezone for email timestamps
18
+ * @default 'Europe/Brussels'
19
+ */
20
+ timezone?: string;
21
+
22
+ /**
23
+ * Maximum number of emails to process per run
24
+ * @default 100
25
+ */
26
+ maxEmailsPerRun?: number;
27
+
28
+ /**
29
+ * Delay between sending emails in milliseconds (for rate limiting)
30
+ * @default 1000 (1 second - respects Resend's 2 requests/second limit)
31
+ */
32
+ delayBetweenEmails?: number;
33
+
34
+ /**
35
+ * Default "from" email address
36
+ * Falls back to process.env.SMTP_FROM_ADDRESS
37
+ */
38
+ defaultFromAddress?: string;
39
+
40
+ /**
41
+ * Optional error reporter (e.g., Sentry)
42
+ */
43
+ errorReporter?: {
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ captureException: (error: Error, context?: any) => void;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Create process-email-queue task with custom configuration
51
+ *
52
+ * @param config - Optional configuration
53
+ * @returns TaskConfig for process-email-queue
54
+ */
55
+ export function createProcessEmailQueueTask(config?: ProcessEmailQueueConfig): TaskConfig<'process-email-queue'> {
56
+ const timezone = config?.timezone || 'Europe/Brussels';
57
+ const maxEmailsPerRun = config?.maxEmailsPerRun || 100;
58
+ const delayBetweenEmails = config?.delayBetweenEmails || 1000;
59
+
60
+ return {
61
+ slug: 'process-email-queue',
62
+ label: 'Process Email Queue',
63
+ retries: {
64
+ attempts: 3,
65
+ backoff: { delay: 2000, type: 'exponential' },
66
+ },
67
+ inputSchema: undefined, // No input - auto-triggered by cron
68
+ outputSchema: [
69
+ {
70
+ name: 'processedCount',
71
+ type: 'number',
72
+ required: true,
73
+ },
74
+ {
75
+ name: 'sentCount',
76
+ type: 'number',
77
+ required: true,
78
+ },
79
+ {
80
+ name: 'failedCount',
81
+ type: 'number',
82
+ required: true,
83
+ },
84
+ ],
85
+
86
+ // Auto-enqueue to run following the cron schedule
87
+ schedule: [
88
+ {
89
+ queue: processEmailQueueAutoRunConfig.queue,
90
+ cron: processEmailQueueAutoRunConfig.cron,
91
+ },
92
+ ],
93
+
94
+ handler: async ({ req }) => {
95
+ const { payload } = req;
96
+ payload.logger.info(`[(Task)ProcessEmailQueue] Task started`);
97
+
98
+ let processedCount = 0;
99
+ let sentCount = 0;
100
+ let failedCount = 0;
101
+
102
+ try {
103
+ // Find all emails that need to be sent (pending or failed with retries remaining)
104
+ // Note: We can't directly compare attemptCount < maxAttempts in the query,
105
+ // so we fetch all pending/failed emails and filter them in code
106
+ const allPendingOrFailed = await payload.find({
107
+ collection: 'queued-emails',
108
+ where: {
109
+ or: [
110
+ {
111
+ status: { equals: 'pending' },
112
+ },
113
+ {
114
+ status: { equals: 'failed' },
115
+ },
116
+ ],
117
+ },
118
+ sort: 'createdAt', // Process oldest emails first
119
+ limit: 200, // Fetch more to filter down to maxEmailsPerRun eligible
120
+ pagination: false,
121
+ });
122
+
123
+ // Filter emails that still have retries remaining
124
+ const emailsToSend = {
125
+ docs: allPendingOrFailed.docs.filter((email) => email.attemptCount < email.maxAttempts).slice(0, maxEmailsPerRun),
126
+ };
127
+
128
+ payload.logger.info(`[(Task)ProcessEmailQueue] Found ${emailsToSend.docs.length} emails to process`);
129
+
130
+ if (emailsToSend.docs.length === 0) {
131
+ return {
132
+ output: {
133
+ processedCount: 0,
134
+ sentCount: 0,
135
+ failedCount: 0,
136
+ },
137
+ };
138
+ }
139
+
140
+ // Process each email
141
+ for (const email of emailsToSend.docs) {
142
+ processedCount++;
143
+
144
+ try {
145
+ payload.logger.info(
146
+ `[(Task)ProcessEmailQueue] Processing email ${email.id} (${email.code}) to ${email.recipient} (attempt ${email.attemptCount + 1}/${email.maxAttempts})`
147
+ );
148
+
149
+ // Mark as sending to prevent duplicate processing
150
+ await payload.update({
151
+ collection: 'queued-emails',
152
+ id: email.id,
153
+ data: {
154
+ status: 'sending',
155
+ lastAttemptAt: toDatabaseTZDate(TZDate.tz(timezone)),
156
+ },
157
+ });
158
+
159
+ // Parse CC if it exists
160
+ const ccArray = email.cc ? email.cc.split(',').map((e: string) => e.trim()) : undefined;
161
+
162
+ const fromAddress = config?.defaultFromAddress || process.env.SMTP_FROM_ADDRESS;
163
+
164
+ // Send the email using Payload's email service
165
+ await payload.sendEmail({
166
+ from: fromAddress,
167
+ to: email.recipient,
168
+ cc: ccArray,
169
+ subject: email.subject,
170
+ html: email.htmlBody,
171
+ });
172
+
173
+ // Mark as sent
174
+ await payload.update({
175
+ collection: 'queued-emails',
176
+ id: email.id,
177
+ data: {
178
+ status: 'sent',
179
+ sentAt: toDatabaseTZDate(TZDate.tz(timezone)),
180
+ attemptCount: email.attemptCount + 1,
181
+ lastError: null,
182
+ },
183
+ });
184
+
185
+ sentCount++;
186
+ payload.logger.info(`[(Task)ProcessEmailQueue] Successfully sent email ${email.id} to ${email.recipient}`);
187
+
188
+ // Add delay between emails to respect rate limits
189
+ // Only delay if there are more emails to process
190
+ if (processedCount < emailsToSend.docs.length) {
191
+ await sleep(delayBetweenEmails);
192
+ }
193
+ } catch (error) {
194
+ failedCount++;
195
+ const errorMessage = error instanceof Error ? error.message : String(error);
196
+ const now = toDatabaseTZDate(TZDate.tz(timezone));
197
+ const newAttemptCount = email.attemptCount + 1;
198
+
199
+ payload.logger.error(`[(Task)ProcessEmailQueue] Failed to send email ${email.id} to ${email.recipient}: ${errorMessage}`);
200
+
201
+ // Append to error history
202
+ const errorHistory = email.errorHistory || [];
203
+ errorHistory.push({
204
+ timestamp: now,
205
+ timestamp_tz: timezone,
206
+ attemptNumber: newAttemptCount,
207
+ error: errorMessage,
208
+ });
209
+
210
+ // Update email status to failed with error message
211
+ await payload.update({
212
+ collection: 'queued-emails',
213
+ id: email.id,
214
+ data: {
215
+ status: 'failed',
216
+ attemptCount: newAttemptCount,
217
+ lastError: errorMessage,
218
+ lastAttemptAt: now,
219
+ errorHistory: errorHistory,
220
+ },
221
+ });
222
+
223
+ // Report to error tracker if configured
224
+ if (config?.errorReporter && error instanceof Error) {
225
+ config.errorReporter.captureException(error, {
226
+ extra: {
227
+ emailId: email.id,
228
+ emailCode: email.code,
229
+ recipient: email.recipient,
230
+ attemptCount: newAttemptCount,
231
+ maxAttempts: email.maxAttempts,
232
+ },
233
+ });
234
+ }
235
+ }
236
+ }
237
+
238
+ payload.logger.info(`[(Task)ProcessEmailQueue] Task completed - Processed: ${processedCount}, Sent: ${sentCount}, Failed: ${failedCount}`);
239
+
240
+ return {
241
+ output: {
242
+ processedCount,
243
+ sentCount,
244
+ failedCount,
245
+ },
246
+ };
247
+ } catch (error) {
248
+ payload.logger.error(`[(Task)ProcessEmailQueue] Critical error: ${error}`);
249
+ if (config?.errorReporter && error instanceof Error) {
250
+ config.errorReporter.captureException(error);
251
+ }
252
+ throw error;
253
+ }
254
+ },
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Default process-email-queue task
260
+ */
261
+ export const processEmailQueueTaskConfig = createProcessEmailQueueTask();