@latte-macchiat-io/latte-payload 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +9 -0
- package/.env.example +29 -0
- package/.github/workflows/ci.yml +160 -0
- package/.github/workflows/publish.yml +126 -0
- package/.nvmrc +1 -0
- package/.prettierignore +2 -0
- package/.prettierrc +11 -0
- package/CHANGELOG.md +87 -0
- package/README.md +364 -0
- package/TESTING_AND_DOCUMENTATION_SETUP.md +348 -0
- package/dist/access/adminAccessOnly.d.ts +25 -0
- package/dist/access/adminAccessOnly.d.ts.map +1 -0
- package/dist/access/adminAccessOnly.js +11 -0
- package/dist/access/adminAccessOnly.js.map +1 -0
- package/dist/access/admins.d.ts +72 -0
- package/dist/access/admins.d.ts.map +1 -0
- package/dist/access/admins.js +76 -0
- package/dist/access/admins.js.map +1 -0
- package/dist/access/anyone.d.ts +35 -0
- package/dist/access/anyone.d.ts.map +1 -0
- package/dist/access/anyone.js +34 -0
- package/dist/access/anyone.js.map +1 -0
- package/dist/access/authenticated.d.ts +63 -0
- package/dist/access/authenticated.d.ts.map +1 -0
- package/dist/access/authenticated.js +68 -0
- package/dist/access/authenticated.js.map +1 -0
- package/dist/access/authenticatedAccessOnly.d.ts +13 -0
- package/dist/access/authenticatedAccessOnly.d.ts.map +1 -0
- package/dist/access/authenticatedAccessOnly.js +11 -0
- package/dist/access/authenticatedAccessOnly.js.map +1 -0
- package/dist/access/index.d.ts +7 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +7 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/publicReadAuthenticatedAccess.d.ts +13 -0
- package/dist/access/publicReadAuthenticatedAccess.d.ts.map +1 -0
- package/dist/access/publicReadAuthenticatedAccess.js +12 -0
- package/dist/access/publicReadAuthenticatedAccess.js.map +1 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.d.ts +31 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.d.ts.map +1 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.js +29 -0
- package/dist/collections/ContactMessages/hooks/sendEmailNotification.js.map +1 -0
- package/dist/collections/ContactMessages/index.d.ts +27 -0
- package/dist/collections/ContactMessages/index.d.ts.map +1 -0
- package/dist/collections/ContactMessages/index.js +81 -0
- package/dist/collections/ContactMessages/index.js.map +1 -0
- package/dist/collections/EmailTemplates/index.d.ts +26 -0
- package/dist/collections/EmailTemplates/index.d.ts.map +1 -0
- package/dist/collections/EmailTemplates/index.js +74 -0
- package/dist/collections/EmailTemplates/index.js.map +1 -0
- package/dist/collections/Media/index.d.ts +3 -0
- package/dist/collections/Media/index.d.ts.map +1 -0
- package/dist/collections/Media/index.js +22 -0
- package/dist/collections/Media/index.js.map +1 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.d.ts +3 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.d.ts.map +1 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.js +11 -0
- package/dist/collections/QueuedEmails/components/HtmlViewer.js.map +1 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.d.ts +2 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.d.ts.map +1 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.js +79 -0
- package/dist/collections/QueuedEmails/components/RetryEmailButtons.js.map +1 -0
- package/dist/collections/QueuedEmails/index.d.ts +3 -0
- package/dist/collections/QueuedEmails/index.d.ts.map +1 -0
- package/dist/collections/QueuedEmails/index.js +245 -0
- package/dist/collections/QueuedEmails/index.js.map +1 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.d.ts +51 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.d.ts.map +1 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.js +90 -0
- package/dist/collections/Users/auth-emails/forgot-password-email.js.map +1 -0
- package/dist/collections/Users/auth-emails/verify-email.d.ts +51 -0
- package/dist/collections/Users/auth-emails/verify-email.d.ts.map +1 -0
- package/dist/collections/Users/auth-emails/verify-email.js +80 -0
- package/dist/collections/Users/auth-emails/verify-email.js.map +1 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.d.ts +3 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.d.ts.map +1 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.js +19 -0
- package/dist/collections/Users/hooks/ensureFirstUserIsAdmin.js.map +1 -0
- package/dist/collections/Users/index.d.ts +76 -0
- package/dist/collections/Users/index.d.ts.map +1 -0
- package/dist/collections/Users/index.js +116 -0
- package/dist/collections/Users/index.js.map +1 -0
- package/dist/collections/index.d.ts +10 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/collections/index.js +14 -0
- package/dist/collections/index.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +4 -0
- package/dist/components/index.js.map +1 -0
- package/dist/forms/states.d.ts +8 -0
- package/dist/forms/states.d.ts.map +1 -0
- package/dist/forms/states.js +16 -0
- package/dist/forms/states.js.map +1 -0
- package/dist/forms/translate-errors.d.ts +8 -0
- package/dist/forms/translate-errors.d.ts.map +1 -0
- package/dist/forms/translate-errors.js +11 -0
- package/dist/forms/translate-errors.js.map +1 -0
- package/dist/forms/validators.d.ts +10 -0
- package/dist/forms/validators.d.ts.map +1 -0
- package/dist/forms/validators.js +23 -0
- package/dist/forms/validators.js.map +1 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.d.ts +2 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.d.ts.map +1 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.js +5 -0
- package/dist/globals/PrivacyPolicy/hooks/revalidate-cache.js.map +1 -0
- package/dist/globals/PrivacyPolicy/index.d.ts +3 -0
- package/dist/globals/PrivacyPolicy/index.d.ts.map +1 -0
- package/dist/globals/PrivacyPolicy/index.js +31 -0
- package/dist/globals/PrivacyPolicy/index.js.map +1 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.d.ts +2 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.d.ts.map +1 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.js +5 -0
- package/dist/globals/TermsOfUse/hooks/revalidate-cache.js.map +1 -0
- package/dist/globals/TermsOfUse/index.d.ts +3 -0
- package/dist/globals/TermsOfUse/index.d.ts.map +1 -0
- package/dist/globals/TermsOfUse/index.js +31 -0
- package/dist/globals/TermsOfUse/index.js.map +1 -0
- package/dist/globals/index.d.ts +3 -0
- package/dist/globals/index.d.ts.map +1 -0
- package/dist/globals/index.js +3 -0
- package/dist/globals/index.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/tasks/index.d.ts +2 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +2 -0
- package/dist/tasks/index.js.map +1 -0
- package/dist/tasks/process-email-queue.d.ts +46 -0
- package/dist/tasks/process-email-queue.d.ts.map +1 -0
- package/dist/tasks/process-email-queue.js +199 -0
- package/dist/tasks/process-email-queue.js.map +1 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/slug.d.ts +2 -0
- package/dist/types/slug.d.ts.map +1 -0
- package/dist/types/slug.js +11 -0
- package/dist/types/slug.js.map +1 -0
- package/dist/utils/database-dates.d.ts +3 -0
- package/dist/utils/database-dates.d.ts.map +1 -0
- package/dist/utils/database-dates.js +6 -0
- package/dist/utils/database-dates.js.map +1 -0
- package/dist/utils/email/generate-email-html.d.ts +23 -0
- package/dist/utils/email/generate-email-html.d.ts.map +1 -0
- package/dist/utils/email/generate-email-html.js +42 -0
- package/dist/utils/email/generate-email-html.js.map +1 -0
- package/dist/utils/email/get-email-template.d.ts +8 -0
- package/dist/utils/email/get-email-template.d.ts.map +1 -0
- package/dist/utils/email/get-email-template.js +14 -0
- package/dist/utils/email/get-email-template.js.map +1 -0
- package/dist/utils/email/index.d.ts +4 -0
- package/dist/utils/email/index.d.ts.map +1 -0
- package/dist/utils/email/index.js +4 -0
- package/dist/utils/email/index.js.map +1 -0
- package/dist/utils/email/queue-email.d.ts +29 -0
- package/dist/utils/email/queue-email.d.ts.map +1 -0
- package/dist/utils/email/queue-email.js +79 -0
- package/dist/utils/email/queue-email.js.map +1 -0
- package/dist/utils/get-global.d.ts +6 -0
- package/dist/utils/get-global.d.ts.map +1 -0
- package/dist/utils/get-global.js +20 -0
- package/dist/utils/get-global.js.map +1 -0
- package/dist/utils/id-from-payload.d.ts +4 -0
- package/dist/utils/id-from-payload.d.ts.map +1 -0
- package/dist/utils/id-from-payload.js +10 -0
- package/dist/utils/id-from-payload.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/migrations.d.ts +2 -0
- package/dist/utils/migrations.d.ts.map +1 -0
- package/dist/utils/migrations.js +18 -0
- package/dist/utils/migrations.js.map +1 -0
- package/dist/utils/payload-client.d.ts +10 -0
- package/dist/utils/payload-client.d.ts.map +1 -0
- package/dist/utils/payload-client.js +14 -0
- package/dist/utils/payload-client.js.map +1 -0
- package/dist/utils/slugify.d.ts +8 -0
- package/dist/utils/slugify.d.ts.map +1 -0
- package/dist/utils/slugify.js +15 -0
- package/dist/utils/slugify.js.map +1 -0
- package/eslint.config.mjs +90 -0
- package/package.json +139 -0
- package/pnpm-workspace.yaml +4 -0
- package/src/access/adminAccessOnly.ts +13 -0
- package/src/access/admins.ts +78 -0
- package/src/access/anyone.ts +35 -0
- package/src/access/authenticated.ts +70 -0
- package/src/access/authenticatedAccessOnly.ts +13 -0
- package/src/access/index.ts +6 -0
- package/src/access/publicReadAuthenticatedAccess.ts +14 -0
- package/src/collections/ContactMessages/hooks/sendEmailNotification.ts +58 -0
- package/src/collections/ContactMessages/index.ts +100 -0
- package/src/collections/EmailTemplates/index.ts +89 -0
- package/src/collections/Media/index.ts +24 -0
- package/src/collections/QueuedEmails/components/HtmlViewer.tsx +16 -0
- package/src/collections/QueuedEmails/components/RetryEmailButtons.tsx +115 -0
- package/src/collections/QueuedEmails/index.ts +246 -0
- package/src/collections/Users/auth-emails/forgot-password-email.ts +135 -0
- package/src/collections/Users/auth-emails/verify-email.ts +123 -0
- package/src/collections/Users/hooks/ensureFirstUserIsAdmin.ts +22 -0
- package/src/collections/Users/index.ts +201 -0
- package/src/collections/index.ts +23 -0
- package/src/components/index.ts +3 -0
- package/src/forms/states.ts +23 -0
- package/src/forms/translate-errors.ts +13 -0
- package/src/forms/validators.ts +33 -0
- package/src/globals/PrivacyPolicy/hooks/revalidate-cache.ts +5 -0
- package/src/globals/PrivacyPolicy/index.ts +33 -0
- package/src/globals/TermsOfUse/hooks/revalidate-cache.ts +5 -0
- package/src/globals/TermsOfUse/index.ts +33 -0
- package/src/globals/index.ts +2 -0
- package/src/index.ts +26 -0
- package/src/tasks/index.ts +7 -0
- package/src/tasks/process-email-queue.ts +261 -0
- package/src/types/index.ts +15 -0
- package/src/types/slug.ts +11 -0
- package/src/utils/database-dates.ts +6 -0
- package/src/utils/email/generate-email-html.ts +63 -0
- package/src/utils/email/get-email-template.ts +18 -0
- package/src/utils/email/index.ts +3 -0
- package/src/utils/email/queue-email.ts +109 -0
- package/src/utils/get-global.ts +25 -0
- package/src/utils/id-from-payload.ts +11 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/migrations.ts +18 -0
- package/src/utils/payload-client.ts +16 -0
- package/src/utils/slugify.ts +21 -0
- package/tests/fixtures/email-template.html +58 -0
- package/tests/fixtures/sample-data.ts +56 -0
- package/tests/helpers/create-test-user.ts +37 -0
- package/tests/helpers/init-payload.ts +59 -0
- package/tests/setup.integration.ts +9 -0
- package/tests/setup.ts +4 -0
- package/tests/unit/access/adminAccessOnly.spec.ts +117 -0
- package/tests/unit/access/admins.spec.ts +68 -0
- package/tests/unit/access/anyone.spec.ts +28 -0
- package/tests/unit/access/authenticated.spec.ts +53 -0
- package/tests/unit/access/authenticatedAccessOnly.spec.ts +112 -0
- package/tests/unit/access/publicReadAuthenticatedAccess.spec.ts +112 -0
- package/tests/unit/forms/validators.spec.ts +348 -0
- package/tests/unit/utils/database-dates.spec.ts +97 -0
- package/tests/unit/utils/id-from-payload.spec.ts +142 -0
- package/tests/unit/utils/slugify.spec.ts +185 -0
- package/tsconfig.json +31 -0
- package/typedoc.json +40 -0
- package/vitest.config.ts +31 -0
- package/vitest.integration.config.ts +27 -0
|
@@ -0,0 +1,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
|
+
};
|