Merge pull request #10546 from appwrite/feat-csv-export

CSV export
This commit is contained in:
Jake Barnby 2025-10-28 18:19:54 +13:00 committed by GitHub
commit 0e6d3279d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3450 additions and 2266 deletions

View file

@ -57,6 +57,21 @@
"emails.recovery.thanks": "Thanks,",
"emails.recovery.buttonText": "Reset password",
"emails.recovery.signature": "{{project}} team",
"emails.csvExport.success.subject": "Your CSV export is ready",
"emails.csvExport.success.preview": "Your data export has been completed successfully.",
"emails.csvExport.success.hello": "Hello {{user}},",
"emails.csvExport.success.body": "Your CSV export is ready for download. Click the link below to download your data export.",
"emails.csvExport.success.footer": "This download link will expire in 1 hour.",
"emails.csvExport.success.thanks": "Thanks,",
"emails.csvExport.success.buttonText": "Download CSV",
"emails.csvExport.success.signature": "{{project}} team",
"emails.csvExport.failure.subject": "Your CSV export failed - file too large",
"emails.csvExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
"emails.csvExport.failure.hello": "Hello {{user}},",
"emails.csvExport.failure.body": "Your CSV export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
"emails.csvExport.failure.footer": "If you have any questions, please contact our support team.",
"emails.csvExport.failure.thanks": "Thanks,",
"emails.csvExport.failure.signature": "{{project}} team",
"emails.invitation.subject": "Invitation to {{team}} Team at {{project}}",
"emails.invitation.preview": "{{owner}} invited you to join {{team}} at {{project}}",
"emails.invitation.hello": "Hello {{user}},",

View file

@ -4934,7 +4934,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 378,
"weight": 379,
"cookies": false,
"type": "",
"demo": "databases\/list-transactions.md",
@ -4999,7 +4999,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 374,
"weight": 375,
"cookies": false,
"type": "",
"demo": "databases\/create-transaction.md",
@ -5067,7 +5067,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 375,
"weight": 376,
"cookies": false,
"type": "",
"demo": "databases\/get-transaction.md",
@ -5129,7 +5129,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 376,
"weight": 377,
"cookies": false,
"type": "",
"demo": "databases\/update-transaction.md",
@ -5205,7 +5205,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 377,
"weight": 378,
"cookies": false,
"type": "",
"demo": "databases\/delete-transaction.md",
@ -5269,7 +5269,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 379,
"weight": 380,
"cookies": false,
"type": "",
"demo": "databases\/create-operations.md",
@ -5352,7 +5352,7 @@
"x-appwrite": {
"method": "listDocuments",
"group": "documents",
"weight": 337,
"weight": 338,
"cookies": false,
"type": "",
"demo": "databases\/list-documents.md",
@ -5451,7 +5451,7 @@
"x-appwrite": {
"method": "createDocument",
"group": "documents",
"weight": 329,
"weight": 330,
"cookies": false,
"type": "",
"demo": "databases\/create-document.md",
@ -5607,7 +5607,7 @@
"x-appwrite": {
"method": "getDocument",
"group": "documents",
"weight": 330,
"weight": 331,
"cookies": false,
"type": "",
"demo": "databases\/get-document.md",
@ -5716,7 +5716,7 @@
"x-appwrite": {
"method": "upsertDocument",
"group": "documents",
"weight": 333,
"weight": 334,
"cookies": false,
"type": "",
"demo": "databases\/upsert-document.md",
@ -5870,7 +5870,7 @@
"x-appwrite": {
"method": "updateDocument",
"group": "documents",
"weight": 331,
"weight": 332,
"cookies": false,
"type": "",
"demo": "databases\/update-document.md",
@ -5978,7 +5978,7 @@
"x-appwrite": {
"method": "deleteDocument",
"group": "documents",
"weight": 335,
"weight": 336,
"cookies": false,
"type": "",
"demo": "databases\/delete-document.md",
@ -6082,7 +6082,7 @@
"x-appwrite": {
"method": "decrementDocumentAttribute",
"group": "documents",
"weight": 340,
"weight": 341,
"cookies": false,
"type": "",
"demo": "databases\/decrement-document-attribute.md",
@ -6206,7 +6206,7 @@
"x-appwrite": {
"method": "incrementDocumentAttribute",
"group": "documents",
"weight": 339,
"weight": 340,
"cookies": false,
"type": "",
"demo": "databases\/increment-document-attribute.md",
@ -6330,7 +6330,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 470,
"weight": 471,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@ -6405,7 +6405,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 468,
"weight": 469,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@ -6521,7 +6521,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 469,
"weight": 470,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@ -7115,7 +7115,7 @@
"x-appwrite": {
"method": "createSubscriber",
"group": "subscribers",
"weight": 298,
"weight": 299,
"cookies": false,
"type": "",
"demo": "messaging\/create-subscriber.md",
@ -7198,7 +7198,7 @@
"x-appwrite": {
"method": "deleteSubscriber",
"group": "subscribers",
"weight": 302,
"weight": 303,
"cookies": false,
"type": "",
"demo": "messaging\/delete-subscriber.md",
@ -8076,7 +8076,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 443,
"weight": 444,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-transactions.md",
@ -8144,7 +8144,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 439,
"weight": 440,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-transaction.md",
@ -8215,7 +8215,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 440,
"weight": 441,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-transaction.md",
@ -8280,7 +8280,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 441,
"weight": 442,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-transaction.md",
@ -8359,7 +8359,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 442,
"weight": 443,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-transaction.md",
@ -8426,7 +8426,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 444,
"weight": 445,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-operations.md",
@ -8512,7 +8512,7 @@
"x-appwrite": {
"method": "listRows",
"group": "rows",
"weight": 435,
"weight": 436,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-rows.md",
@ -8610,7 +8610,7 @@
"x-appwrite": {
"method": "createRow",
"group": "rows",
"weight": 427,
"weight": 428,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-row.md",
@ -8761,7 +8761,7 @@
"x-appwrite": {
"method": "getRow",
"group": "rows",
"weight": 428,
"weight": 429,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-row.md",
@ -8869,7 +8869,7 @@
"x-appwrite": {
"method": "upsertRow",
"group": "rows",
"weight": 431,
"weight": 432,
"cookies": false,
"type": "",
"demo": "tablesdb\/upsert-row.md",
@ -9014,7 +9014,7 @@
"x-appwrite": {
"method": "updateRow",
"group": "rows",
"weight": 429,
"weight": 430,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-row.md",
@ -9121,7 +9121,7 @@
"x-appwrite": {
"method": "deleteRow",
"group": "rows",
"weight": 433,
"weight": 434,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-row.md",
@ -9224,7 +9224,7 @@
"x-appwrite": {
"method": "decrementRowColumn",
"group": "rows",
"weight": 438,
"weight": 439,
"cookies": false,
"type": "",
"demo": "tablesdb\/decrement-row-column.md",
@ -9347,7 +9347,7 @@
"x-appwrite": {
"method": "incrementRowColumn",
"group": "rows",
"weight": 437,
"weight": 438,
"cookies": false,
"type": "",
"demo": "tablesdb\/increment-row-column.md",
@ -12514,13 +12514,14 @@
},
"status": {
"type": "string",
"description": "The status of the function execution. Possible values can be: `waiting`, `processing`, `completed`, or `failed`.",
"description": "The status of the function execution. Possible values can be: `waiting`, `processing`, `completed`, `failed`, or `scheduled`.",
"x-example": "processing",
"enum": [
"waiting",
"processing",
"completed",
"failed"
"failed",
"scheduled"
]
},
"requestMethod": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4934,7 +4934,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 378,
"weight": 379,
"cookies": false,
"type": "",
"demo": "databases\/list-transactions.md",
@ -4999,7 +4999,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 374,
"weight": 375,
"cookies": false,
"type": "",
"demo": "databases\/create-transaction.md",
@ -5067,7 +5067,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 375,
"weight": 376,
"cookies": false,
"type": "",
"demo": "databases\/get-transaction.md",
@ -5129,7 +5129,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 376,
"weight": 377,
"cookies": false,
"type": "",
"demo": "databases\/update-transaction.md",
@ -5205,7 +5205,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 377,
"weight": 378,
"cookies": false,
"type": "",
"demo": "databases\/delete-transaction.md",
@ -5269,7 +5269,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 379,
"weight": 380,
"cookies": false,
"type": "",
"demo": "databases\/create-operations.md",
@ -5352,7 +5352,7 @@
"x-appwrite": {
"method": "listDocuments",
"group": "documents",
"weight": 337,
"weight": 338,
"cookies": false,
"type": "",
"demo": "databases\/list-documents.md",
@ -5451,7 +5451,7 @@
"x-appwrite": {
"method": "createDocument",
"group": "documents",
"weight": 329,
"weight": 330,
"cookies": false,
"type": "",
"demo": "databases\/create-document.md",
@ -5607,7 +5607,7 @@
"x-appwrite": {
"method": "getDocument",
"group": "documents",
"weight": 330,
"weight": 331,
"cookies": false,
"type": "",
"demo": "databases\/get-document.md",
@ -5716,7 +5716,7 @@
"x-appwrite": {
"method": "upsertDocument",
"group": "documents",
"weight": 333,
"weight": 334,
"cookies": false,
"type": "",
"demo": "databases\/upsert-document.md",
@ -5870,7 +5870,7 @@
"x-appwrite": {
"method": "updateDocument",
"group": "documents",
"weight": 331,
"weight": 332,
"cookies": false,
"type": "",
"demo": "databases\/update-document.md",
@ -5978,7 +5978,7 @@
"x-appwrite": {
"method": "deleteDocument",
"group": "documents",
"weight": 335,
"weight": 336,
"cookies": false,
"type": "",
"demo": "databases\/delete-document.md",
@ -6082,7 +6082,7 @@
"x-appwrite": {
"method": "decrementDocumentAttribute",
"group": "documents",
"weight": 340,
"weight": 341,
"cookies": false,
"type": "",
"demo": "databases\/decrement-document-attribute.md",
@ -6206,7 +6206,7 @@
"x-appwrite": {
"method": "incrementDocumentAttribute",
"group": "documents",
"weight": 339,
"weight": 340,
"cookies": false,
"type": "",
"demo": "databases\/increment-document-attribute.md",
@ -6330,7 +6330,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 470,
"weight": 471,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@ -6405,7 +6405,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 468,
"weight": 469,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@ -6521,7 +6521,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 469,
"weight": 470,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@ -7115,7 +7115,7 @@
"x-appwrite": {
"method": "createSubscriber",
"group": "subscribers",
"weight": 298,
"weight": 299,
"cookies": false,
"type": "",
"demo": "messaging\/create-subscriber.md",
@ -7198,7 +7198,7 @@
"x-appwrite": {
"method": "deleteSubscriber",
"group": "subscribers",
"weight": 302,
"weight": 303,
"cookies": false,
"type": "",
"demo": "messaging\/delete-subscriber.md",
@ -8076,7 +8076,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 443,
"weight": 444,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-transactions.md",
@ -8144,7 +8144,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 439,
"weight": 440,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-transaction.md",
@ -8215,7 +8215,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 440,
"weight": 441,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-transaction.md",
@ -8280,7 +8280,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 441,
"weight": 442,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-transaction.md",
@ -8359,7 +8359,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 442,
"weight": 443,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-transaction.md",
@ -8426,7 +8426,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 444,
"weight": 445,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-operations.md",
@ -8512,7 +8512,7 @@
"x-appwrite": {
"method": "listRows",
"group": "rows",
"weight": 435,
"weight": 436,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-rows.md",
@ -8610,7 +8610,7 @@
"x-appwrite": {
"method": "createRow",
"group": "rows",
"weight": 427,
"weight": 428,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-row.md",
@ -8761,7 +8761,7 @@
"x-appwrite": {
"method": "getRow",
"group": "rows",
"weight": 428,
"weight": 429,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-row.md",
@ -8869,7 +8869,7 @@
"x-appwrite": {
"method": "upsertRow",
"group": "rows",
"weight": 431,
"weight": 432,
"cookies": false,
"type": "",
"demo": "tablesdb\/upsert-row.md",
@ -9014,7 +9014,7 @@
"x-appwrite": {
"method": "updateRow",
"group": "rows",
"weight": 429,
"weight": 430,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-row.md",
@ -9121,7 +9121,7 @@
"x-appwrite": {
"method": "deleteRow",
"group": "rows",
"weight": 433,
"weight": 434,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-row.md",
@ -9224,7 +9224,7 @@
"x-appwrite": {
"method": "decrementRowColumn",
"group": "rows",
"weight": 438,
"weight": 439,
"cookies": false,
"type": "",
"demo": "tablesdb\/decrement-row-column.md",
@ -9347,7 +9347,7 @@
"x-appwrite": {
"method": "incrementRowColumn",
"group": "rows",
"weight": 437,
"weight": 438,
"cookies": false,
"type": "",
"demo": "tablesdb\/increment-row-column.md",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -5076,7 +5076,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 378,
"weight": 379,
"cookies": false,
"type": "",
"demo": "databases\/list-transactions.md",
@ -5141,7 +5141,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 374,
"weight": 375,
"cookies": false,
"type": "",
"demo": "databases\/create-transaction.md",
@ -5209,7 +5209,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 375,
"weight": 376,
"cookies": false,
"type": "",
"demo": "databases\/get-transaction.md",
@ -5270,7 +5270,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 376,
"weight": 377,
"cookies": false,
"type": "",
"demo": "databases\/update-transaction.md",
@ -5347,7 +5347,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 377,
"weight": 378,
"cookies": false,
"type": "",
"demo": "databases\/delete-transaction.md",
@ -5410,7 +5410,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 379,
"weight": 380,
"cookies": false,
"type": "",
"demo": "databases\/create-operations.md",
@ -5489,7 +5489,7 @@
"x-appwrite": {
"method": "listDocuments",
"group": "documents",
"weight": 337,
"weight": 338,
"cookies": false,
"type": "",
"demo": "databases\/list-documents.md",
@ -5582,7 +5582,7 @@
"x-appwrite": {
"method": "createDocument",
"group": "documents",
"weight": 329,
"weight": 330,
"cookies": false,
"type": "",
"demo": "databases\/create-document.md",
@ -5736,7 +5736,7 @@
"x-appwrite": {
"method": "getDocument",
"group": "documents",
"weight": 330,
"weight": 331,
"cookies": false,
"type": "",
"demo": "databases\/get-document.md",
@ -5837,7 +5837,7 @@
"x-appwrite": {
"method": "upsertDocument",
"group": "documents",
"weight": 333,
"weight": 334,
"cookies": false,
"type": "",
"demo": "databases\/upsert-document.md",
@ -5987,7 +5987,7 @@
"x-appwrite": {
"method": "updateDocument",
"group": "documents",
"weight": 331,
"weight": 332,
"cookies": false,
"type": "",
"demo": "databases\/update-document.md",
@ -6093,7 +6093,7 @@
"x-appwrite": {
"method": "deleteDocument",
"group": "documents",
"weight": 335,
"weight": 336,
"cookies": false,
"type": "",
"demo": "databases\/delete-document.md",
@ -6191,7 +6191,7 @@
"x-appwrite": {
"method": "decrementDocumentAttribute",
"group": "documents",
"weight": 340,
"weight": 341,
"cookies": false,
"type": "",
"demo": "databases\/decrement-document-attribute.md",
@ -6309,7 +6309,7 @@
"x-appwrite": {
"method": "incrementDocumentAttribute",
"group": "documents",
"weight": 339,
"weight": 340,
"cookies": false,
"type": "",
"demo": "databases\/increment-document-attribute.md",
@ -6425,7 +6425,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 470,
"weight": 471,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@ -6498,7 +6498,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 468,
"weight": 469,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@ -6615,7 +6615,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 469,
"weight": 470,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@ -7240,7 +7240,7 @@
"x-appwrite": {
"method": "createSubscriber",
"group": "subscribers",
"weight": 298,
"weight": 299,
"cookies": false,
"type": "",
"demo": "messaging\/create-subscriber.md",
@ -7324,7 +7324,7 @@
"x-appwrite": {
"method": "deleteSubscriber",
"group": "subscribers",
"weight": 302,
"weight": 303,
"cookies": false,
"type": "",
"demo": "messaging\/delete-subscriber.md",
@ -8153,7 +8153,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 443,
"weight": 444,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-transactions.md",
@ -8221,7 +8221,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 439,
"weight": 440,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-transaction.md",
@ -8292,7 +8292,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 440,
"weight": 441,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-transaction.md",
@ -8356,7 +8356,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 441,
"weight": 442,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-transaction.md",
@ -8436,7 +8436,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 442,
"weight": 443,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-transaction.md",
@ -8502,7 +8502,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 444,
"weight": 445,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-operations.md",
@ -8584,7 +8584,7 @@
"x-appwrite": {
"method": "listRows",
"group": "rows",
"weight": 435,
"weight": 436,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-rows.md",
@ -8676,7 +8676,7 @@
"x-appwrite": {
"method": "createRow",
"group": "rows",
"weight": 427,
"weight": 428,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-row.md",
@ -8825,7 +8825,7 @@
"x-appwrite": {
"method": "getRow",
"group": "rows",
"weight": 428,
"weight": 429,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-row.md",
@ -8925,7 +8925,7 @@
"x-appwrite": {
"method": "upsertRow",
"group": "rows",
"weight": 431,
"weight": 432,
"cookies": false,
"type": "",
"demo": "tablesdb\/upsert-row.md",
@ -9066,7 +9066,7 @@
"x-appwrite": {
"method": "updateRow",
"group": "rows",
"weight": 429,
"weight": 430,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-row.md",
@ -9171,7 +9171,7 @@
"x-appwrite": {
"method": "deleteRow",
"group": "rows",
"weight": 433,
"weight": 434,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-row.md",
@ -9268,7 +9268,7 @@
"x-appwrite": {
"method": "decrementRowColumn",
"group": "rows",
"weight": 438,
"weight": 439,
"cookies": false,
"type": "",
"demo": "tablesdb\/decrement-row-column.md",
@ -9385,7 +9385,7 @@
"x-appwrite": {
"method": "incrementRowColumn",
"group": "rows",
"weight": 437,
"weight": 438,
"cookies": false,
"type": "",
"demo": "tablesdb\/increment-row-column.md",
@ -12504,13 +12504,14 @@
},
"status": {
"type": "string",
"description": "The status of the function execution. Possible values can be: `waiting`, `processing`, `completed`, or `failed`.",
"description": "The status of the function execution. Possible values can be: `waiting`, `processing`, `completed`, `failed`, or `scheduled`.",
"x-example": "processing",
"enum": [
"waiting",
"processing",
"completed",
"failed"
"failed",
"scheduled"
]
},
"requestMethod": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -5076,7 +5076,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 378,
"weight": 379,
"cookies": false,
"type": "",
"demo": "databases\/list-transactions.md",
@ -5141,7 +5141,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 374,
"weight": 375,
"cookies": false,
"type": "",
"demo": "databases\/create-transaction.md",
@ -5209,7 +5209,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 375,
"weight": 376,
"cookies": false,
"type": "",
"demo": "databases\/get-transaction.md",
@ -5270,7 +5270,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 376,
"weight": 377,
"cookies": false,
"type": "",
"demo": "databases\/update-transaction.md",
@ -5347,7 +5347,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 377,
"weight": 378,
"cookies": false,
"type": "",
"demo": "databases\/delete-transaction.md",
@ -5410,7 +5410,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 379,
"weight": 380,
"cookies": false,
"type": "",
"demo": "databases\/create-operations.md",
@ -5489,7 +5489,7 @@
"x-appwrite": {
"method": "listDocuments",
"group": "documents",
"weight": 337,
"weight": 338,
"cookies": false,
"type": "",
"demo": "databases\/list-documents.md",
@ -5582,7 +5582,7 @@
"x-appwrite": {
"method": "createDocument",
"group": "documents",
"weight": 329,
"weight": 330,
"cookies": false,
"type": "",
"demo": "databases\/create-document.md",
@ -5736,7 +5736,7 @@
"x-appwrite": {
"method": "getDocument",
"group": "documents",
"weight": 330,
"weight": 331,
"cookies": false,
"type": "",
"demo": "databases\/get-document.md",
@ -5837,7 +5837,7 @@
"x-appwrite": {
"method": "upsertDocument",
"group": "documents",
"weight": 333,
"weight": 334,
"cookies": false,
"type": "",
"demo": "databases\/upsert-document.md",
@ -5987,7 +5987,7 @@
"x-appwrite": {
"method": "updateDocument",
"group": "documents",
"weight": 331,
"weight": 332,
"cookies": false,
"type": "",
"demo": "databases\/update-document.md",
@ -6093,7 +6093,7 @@
"x-appwrite": {
"method": "deleteDocument",
"group": "documents",
"weight": 335,
"weight": 336,
"cookies": false,
"type": "",
"demo": "databases\/delete-document.md",
@ -6191,7 +6191,7 @@
"x-appwrite": {
"method": "decrementDocumentAttribute",
"group": "documents",
"weight": 340,
"weight": 341,
"cookies": false,
"type": "",
"demo": "databases\/decrement-document-attribute.md",
@ -6309,7 +6309,7 @@
"x-appwrite": {
"method": "incrementDocumentAttribute",
"group": "documents",
"weight": 339,
"weight": 340,
"cookies": false,
"type": "",
"demo": "databases\/increment-document-attribute.md",
@ -6425,7 +6425,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 470,
"weight": 471,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@ -6498,7 +6498,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 468,
"weight": 469,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@ -6615,7 +6615,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 469,
"weight": 470,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@ -7240,7 +7240,7 @@
"x-appwrite": {
"method": "createSubscriber",
"group": "subscribers",
"weight": 298,
"weight": 299,
"cookies": false,
"type": "",
"demo": "messaging\/create-subscriber.md",
@ -7324,7 +7324,7 @@
"x-appwrite": {
"method": "deleteSubscriber",
"group": "subscribers",
"weight": 302,
"weight": 303,
"cookies": false,
"type": "",
"demo": "messaging\/delete-subscriber.md",
@ -8153,7 +8153,7 @@
"x-appwrite": {
"method": "listTransactions",
"group": "transactions",
"weight": 443,
"weight": 444,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-transactions.md",
@ -8221,7 +8221,7 @@
"x-appwrite": {
"method": "createTransaction",
"group": "transactions",
"weight": 439,
"weight": 440,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-transaction.md",
@ -8292,7 +8292,7 @@
"x-appwrite": {
"method": "getTransaction",
"group": "transactions",
"weight": 440,
"weight": 441,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-transaction.md",
@ -8356,7 +8356,7 @@
"x-appwrite": {
"method": "updateTransaction",
"group": "transactions",
"weight": 441,
"weight": 442,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-transaction.md",
@ -8436,7 +8436,7 @@
"x-appwrite": {
"method": "deleteTransaction",
"group": "transactions",
"weight": 442,
"weight": 443,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-transaction.md",
@ -8502,7 +8502,7 @@
"x-appwrite": {
"method": "createOperations",
"group": "transactions",
"weight": 444,
"weight": 445,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-operations.md",
@ -8584,7 +8584,7 @@
"x-appwrite": {
"method": "listRows",
"group": "rows",
"weight": 435,
"weight": 436,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-rows.md",
@ -8676,7 +8676,7 @@
"x-appwrite": {
"method": "createRow",
"group": "rows",
"weight": 427,
"weight": 428,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-row.md",
@ -8825,7 +8825,7 @@
"x-appwrite": {
"method": "getRow",
"group": "rows",
"weight": 428,
"weight": 429,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-row.md",
@ -8925,7 +8925,7 @@
"x-appwrite": {
"method": "upsertRow",
"group": "rows",
"weight": 431,
"weight": 432,
"cookies": false,
"type": "",
"demo": "tablesdb\/upsert-row.md",
@ -9066,7 +9066,7 @@
"x-appwrite": {
"method": "updateRow",
"group": "rows",
"weight": 429,
"weight": 430,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-row.md",
@ -9171,7 +9171,7 @@
"x-appwrite": {
"method": "deleteRow",
"group": "rows",
"weight": 433,
"weight": 434,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-row.md",
@ -9268,7 +9268,7 @@
"x-appwrite": {
"method": "decrementRowColumn",
"group": "rows",
"weight": 438,
"weight": 439,
"cookies": false,
"type": "",
"demo": "tablesdb\/decrement-row-column.md",
@ -9385,7 +9385,7 @@
"x-appwrite": {
"method": "incrementRowColumn",
"group": "rows",
"weight": 437,
"weight": 438,
"cookies": false,
"type": "",
"demo": "tablesdb\/increment-row-column.md",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
<?php
use Appwrite\Auth\Auth;
use Appwrite\Event\Event;
use Appwrite\Event\Migration;
use Appwrite\Extend\Exception;
@ -20,6 +19,7 @@ use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Queries\Documents;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Migration\Resource;
@ -307,7 +307,8 @@ App::post('/v1/migrations/nhost')
->dynamic($migration, Response::MODEL_MIGRATION);
});
App::post('/v1/migrations/csv')
App::post('/v1/migrations/csv/imports')
->alias('/v1/migrations/csv')
->groups(['api', 'migrations'])
->desc('Import documents from a CSV')
->label('scope', 'migrations.write')
@ -316,8 +317,8 @@ App::post('/v1/migrations/csv')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createCsvMigration',
description: '/docs/references/migrations/migration-csv.md',
name: 'createCSVImport',
description: '/docs/references/migrations/migration-csv-import.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
@ -335,15 +336,23 @@ App::post('/v1/migrations/csv')
->inject('dbForPlatform')
->inject('project')
->inject('deviceForFiles')
->inject('deviceForImports')
->inject('deviceForMigrations')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (string $bucketId, string $fileId, string $resourceId, bool $internalFile, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project, Device $deviceForFiles, Device $deviceForImports, Event $queueForEvents, Migration $queueForMigrations) {
$isAPIKey = Auth::isAppUser(Authorization::getRoles());
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles());
if ($internalFile && !$isPrivilegedUser) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}
->action(function (
string $bucketId,
string $fileId,
string $resourceId,
bool $internalFile,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Document $project,
Device $deviceForFiles,
Device $deviceForMigrations,
Event $queueForEvents,
Migration $queueForMigrations
) {
$bucket = Authorization::skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
if ($internalFile) {
return $dbForPlatform->getDocument('buckets', 'default');
@ -351,7 +360,7 @@ App::post('/v1/migrations/csv')
return $dbForProject->getDocument('buckets', $bucketId);
});
if ($bucket->isEmpty() || (!$isAPIKey && !$isPrivilegedUser)) {
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
@ -365,18 +374,17 @@ App::post('/v1/migrations/csv')
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
}
// no encryption, compression on files above 20MB.
// No encryption or compression on files above 20MB.
$hasEncryption = !empty($file->getAttribute('openSSLCipher'));
$compression = $file->getAttribute('algorithm', Compression::NONE);
$hasCompression = $compression !== Compression::NONE;
$migrationId = ID::unique();
$newPath = $deviceForImports->getPath($migrationId . '_' . $fileId . '.csv');
$newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv');
if ($hasEncryption || $hasCompression) {
$source = $deviceForFiles->read($path);
// 1. decrypt
if ($hasEncryption) {
$source = OpenSSL::decrypt(
$source,
@ -388,7 +396,6 @@ App::post('/v1/migrations/csv')
);
}
// 2. decompress
if ($hasCompression) {
switch ($compression) {
case Compression::ZSTD:
@ -400,15 +407,15 @@ App::post('/v1/migrations/csv')
}
}
// manual write after decryption and/or decompression
if (! $deviceForImports->write($newPath, $source, 'text/csv')) {
throw new \Exception("Unable to copy file");
// Manual write after decryption and/or decompression
if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) {
throw new \Exception('Unable to copy file');
}
} elseif (! $deviceForFiles->transfer($path, $newPath, $deviceForImports)) {
throw new \Exception("Unable to copy file");
} elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
throw new \Exception('Unable to copy file');
}
$fileSize = $deviceForImports->getFileSize($newPath);
$fileSize = $deviceForMigrations->getFileSize($newPath);
$resources = Transfer::extractServices([Transfer::GROUP_DATABASES]);
$migration = $dbForProject->createDocument('migrations', new Document([
@ -441,6 +448,136 @@ App::post('/v1/migrations/csv')
->dynamic($migration, Response::MODEL_MIGRATION);
});
App::post('/v1/migrations/csv/exports')
->groups(['api', 'migrations'])
->desc('Export documents to CSV')
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createCSVExport',
description: '/docs/references/migrations/migration-csv-export.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
->param('bucketId', '', new UID(), 'Storage bucket unique ID where the exported CSV will be stored.')
->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.')
->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true)
->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true)
->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true)
->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true)
->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
->inject('user')
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (
string $resourceId,
string $bucketId,
string $filename,
array $columns,
array $queries,
string $delimiter,
string $enclosure,
string $escape,
bool $header,
bool $notify,
Document $user,
Response $response,
Database $dbForProject,
Document $project,
Event $queueForEvents,
Migration $queueForMigrations
) {
try {
$parsedQueries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
[$databaseId, $collectionId] = \explode(':', $resourceId, 2);
if (empty($databaseId)) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
if (empty($collectionId)) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
$collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
if ($collection->isEmpty()) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$validator = new Documents(
attributes: $collection->getAttribute('attributes', []),
indexes: $collection->getAttribute('indexes', []),
idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
);
if (!$validator->isValid($parsedQueries)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => Appwrite::getName(),
'destination' => CSV::getName(),
'resources' => Transfer::extractServices([Transfer::GROUP_DATABASES]),
'resourceId' => $resourceId,
'resourceType' => Resource::TYPE_DATABASE,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'bucketId' => $bucketId,
'filename' => $filename,
'columns' => $columns,
'queries' => $queries,
'delimiter' => $delimiter,
'enclosure' => $enclosure,
'escape' => $escape,
'header' => $header,
'notify' => $notify,
'userInternalId' => $user->getSequence(),
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->trigger();
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
});
App::get('/v1/migrations')
->groups(['api', 'migrations'])
->desc('List migrations')

View file

@ -555,7 +555,7 @@ App::setResource('deviceForFiles', function ($project, Telemetry $telemetry) {
App::setResource('deviceForSites', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
App::setResource('deviceForImports', function ($project, Telemetry $telemetry) {
App::setResource('deviceForMigrations', function ($project, Telemetry $telemetry) {
return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
App::setResource('deviceForFunctions', function ($project, Telemetry $telemetry) {

View file

@ -349,7 +349,7 @@ Server::setResource('deviceForSites', function (Document $project, Telemetry $te
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
}, ['project', 'telemetry']);
Server::setResource('deviceForImports', function (Document $project, Telemetry $telemetry) {
Server::setResource('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
}, ['project', 'telemetry']);

View file

@ -698,6 +698,7 @@ services:
- appwrite
volumes:
- appwrite-imports:/storage/imports:rw
- appwrite-uploads:/storage/uploads:rw
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
- ./tests:/usr/src/code/tests

View file

@ -0,0 +1 @@
Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in an Appwrite Storage bucket.

View file

@ -3,8 +3,10 @@
namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Mail;
use Appwrite\Event\Realtime;
use Exception;
use Appwrite\Extend\Exception;
use Appwrite\Template\Template;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Database\Database;
@ -13,10 +15,15 @@ use Utopia\Database\Exception\Authorization;
use Utopia\Database\Exception\Conflict;
use Utopia\Database\Exception\Restricted;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Locale\Locale;
use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite;
use Utopia\Migration\Destinations\CSV as DestinationCSV;
use Utopia\Migration\Exception as MigrationException;
use Utopia\Migration\Source;
use Utopia\Migration\Sources\Appwrite;
use Utopia\Migration\Sources\Appwrite as SourceAppwrite;
use Utopia\Migration\Sources\CSV;
use Utopia\Migration\Sources\Firebase;
@ -25,6 +32,7 @@ use Utopia\Migration\Sources\Supabase;
use Utopia\Migration\Transfer;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Storage\Compression\Compression;
use Utopia\Storage\Device;
use Utopia\System\System;
@ -34,13 +42,14 @@ class Migrations extends Action
protected Database $dbForPlatform;
protected Device $deviceForImports;
protected Device $deviceForMigrations;
protected Device $deviceForFiles;
protected Document $project;
protected array $plan;
/**
* Cached for performance.
*
* @var array<string, int>
*/
protected array $sourceReport = [];
@ -68,23 +77,38 @@ class Migrations extends Action
->inject('dbForPlatform')
->inject('logError')
->inject('queueForRealtime')
->inject('deviceForImports')
->inject('deviceForMigrations')
->inject('deviceForFiles')
->inject('queueForMails')
->inject('plan')
->callback($this->action(...));
}
/**
* @throws Exception
*/
public function action(Message $message, Document $project, Database $dbForProject, Database $dbForPlatform, callable $logError, Realtime $queueForRealtime, Device $deviceForImports): void
{
public function action(
Message $message,
Document $project,
Database $dbForProject,
Database $dbForPlatform,
callable $logError,
Realtime $queueForRealtime,
Device $deviceForMigrations,
Device $deviceForFiles,
Mail $queueForMails,
array $plan,
): void {
$payload = $message->getPayload() ?? [];
$this->deviceForImports = $deviceForImports;
$this->deviceForMigrations = $deviceForMigrations;
$this->deviceForFiles = $deviceForFiles;
$this->plan = $plan;
if (empty($payload)) {
throw new Exception('Missing payload');
}
$events = $payload['events'] ?? [];
$events = $payload['events'] ?? [];
$migration = new Document($payload['migration'] ?? []);
if ($project->getId() === 'console') {
@ -96,14 +120,11 @@ class Migrations extends Action
$this->project = $project;
$this->logError = $logError;
/**
* Handle Event execution.
*/
if (! empty($events)) {
if (!empty($events)) {
return;
}
$this->processMigration($migration, $queueForRealtime);
$this->processMigration($migration, $queueForRealtime, $queueForMails);
}
/**
@ -112,9 +133,19 @@ class Migrations extends Action
protected function processSource(Document $migration): Source
{
$source = $migration->getAttribute('source');
$destination = $migration->getAttribute('destination');
$resourceId = $migration->getAttribute('resourceId');
$credentials = $migration->getAttribute('credentials');
$migrationOptions = $migration->getAttribute('options');
$dataSource = Appwrite::SOURCE_API;
$database = null;
$queries = [];
if ($source === Appwrite::getName() && $destination === DestinationCSV::getName()) {
$dataSource = Appwrite::SOURCE_DATABASE;
$database = $this->dbForProject;
$queries = Query::parseQueries($migrationOptions['queries']);
}
$migrationSource = match ($source) {
Firebase::getName() => new Firebase(
@ -142,11 +173,14 @@ class Migrations extends Action
$credentials['projectId'],
$credentials['endpoint'] === 'http://localhost/v1' ? 'http://appwrite/v1' : $credentials['endpoint'],
$credentials['apiKey'],
$dataSource,
$database,
$queries,
),
CSV::getName() => new CSV(
$resourceId,
$migrationOptions['path'],
$this->deviceForImports,
$this->deviceForMigrations,
$this->dbForProject
),
default => throw new \Exception('Invalid source type'),
@ -163,6 +197,7 @@ class Migrations extends Action
protected function processDestination(Document $migration, string $apiKey): Destination
{
$destination = $migration->getAttribute('destination');
$options = $migration->getAttribute('options', []);
return match ($destination) {
DestinationAppwrite::getName() => new DestinationAppwrite(
@ -172,6 +207,17 @@ class Migrations extends Action
$this->dbForProject,
Config::getParam('collections', [])['databases']['collections'],
),
DestinationCSV::getName() => new DestinationCSV(
$this->deviceForFiles,
$migration->getAttribute('resourceId'),
$options['bucketId'],
$options['filename'],
$options['columns'],
$options['delimiter'],
$options['enclosure'],
$options['escape'],
$options['header'],
),
default => throw new \Exception('Invalid destination type'),
};
}
@ -185,35 +231,19 @@ class Migrations extends Action
*/
protected function updateMigrationDocument(Document $migration, Document $project, Realtime $queueForRealtime): Document
{
$errorMessages = [];
$clonedMigrationDocument = clone $migration;
// we cannot use #sensitive because
// `errors` is nested which requires an override.
$errors = $clonedMigrationDocument->getAttribute('errors', []);
foreach ($errors as $error) {
$decoded = json_decode($error, true);
if (is_array($decoded) && isset($decoded['trace'])) {
unset($decoded['trace']);
$errorMessages[] = json_encode($decoded);
}
}
// set the errors back without trace
$clonedMigrationDocument->setAttribute('errors', $errorMessages);
/** Trigger Realtime Events */
$queueForRealtime
->setProject($project)
->setSubscribers(['console', $project->getId()])
->setEvent('migrations.[migrationId].update')
->setParam('migrationId', $migration->getId())
->setPayload($clonedMigrationDocument->getArrayCopy(), ['options', 'credentials'])
->setPayload($migration->getArrayCopy(), sensitive: ['credentials'])
->trigger();
return $this->dbForProject->updateDocument('migrations', $migration->getId(), $migration);
return $this->dbForProject->updateDocument(
'migrations',
$migration->getId(),
$migration
);
}
/**
@ -243,13 +273,6 @@ class Migrations extends Action
'files.write',
'functions.read',
'functions.write',
'databases.read',
'collections.read',
'tables.read',
'documents.read',
'documents.write',
'rows.read',
'rows.write',
'tokens.read',
'tokens.write',
]
@ -266,11 +289,13 @@ class Migrations extends Action
* @throws \Utopia\Database\Exception
* @throws Exception
*/
protected function processMigration(Document $migration, Realtime $queueForRealtime): void
{
$project = $this->project;
$projectDocument = $this->dbForPlatform->getDocument('projects', $project->getId());
$tempAPIKey = $this->generateAPIKey($projectDocument);
protected function processMigration(
Document $migration,
Realtime $queueForRealtime,
Mail $queueForMails,
): void {
$project = $this->dbForPlatform->getDocument('projects', $this->project->getId());
$tempAPIKey = $this->generateAPIKey($project);
$transfer = $source = $destination = null;
@ -280,17 +305,15 @@ class Migrations extends Action
empty($migration->getAttribute('credentials', []))
) {
$credentials = $migration->getAttribute('credentials', []);
$credentials['projectId'] = $credentials['projectId'] ?? $projectDocument->getId();
$credentials['projectId'] = $credentials['projectId'] ?? $project->getId();
$credentials['endpoint'] = $credentials['endpoint'] ?? 'http://appwrite/v1';
$credentials['apiKey'] = $credentials['apiKey'] ?? $tempAPIKey;
$migration->setAttribute('credentials', $credentials);
}
$migration->setAttribute('stage', 'processing');
$migration->setAttribute('status', 'processing');
$this->updateMigrationDocument($migration, $projectDocument, $queueForRealtime);
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
$source = $this->processSource($migration);
$destination = $this->processDestination($migration, $tempAPIKey);
@ -303,40 +326,30 @@ class Migrations extends Action
/** Start Transfer */
if (empty($source->getErrors())) {
$migration->setAttribute('stage', 'migrating');
$this->updateMigrationDocument($migration, $projectDocument, $queueForRealtime);
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
$transfer->run(
$migration->getAttribute('resources'),
function () use ($migration, $transfer, $projectDocument, $queueForRealtime) {
function () use ($migration, $transfer, $project, $queueForRealtime) {
$migration->setAttribute('resourceData', json_encode($transfer->getCache()));
$migration->setAttribute('statusCounters', json_encode($transfer->getStatusCounters()));
$this->updateMigrationDocument($migration, $projectDocument, $queueForRealtime);
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
},
$migration->getAttribute('resourceId'),
$migration->getAttribute('resourceType')
);
}
$destination->shutDown();
$source->shutDown();
$destination->shutdown();
$source->shutdown();
$sourceErrors = $source->getErrors();
$destinationErrors = $destination->getErrors();
if (! empty($sourceErrors) || ! empty($destinationErrors)) {
if (!empty($sourceErrors) || ! empty($destinationErrors)) {
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
$errorMessages = [];
foreach ($sourceErrors as $error) {
$errorMessages[] = json_encode($error);
}
foreach ($destinationErrors as $error) {
$errorMessages[] = json_encode($error);
}
$migration->setAttribute('errors', $errorMessages);
$migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors));
return;
}
@ -362,58 +375,286 @@ class Migrations extends Action
if ($transfer) {
$sourceErrors = $source->getErrors();
$destinationErrors = $destination->getErrors();
$errorMessages = [];
foreach ($sourceErrors as $error) {
$errorMessages[] = json_encode($error);
}
foreach ($destinationErrors as $error) {
$errorMessages[] = json_encode($error);
}
$migration->setAttribute('errors', $errorMessages);
$migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors));
}
} finally {
$this->updateMigrationDocument($migration, $projectDocument, $queueForRealtime);
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
if ($migration->getAttribute('status', '') === 'failed') {
Console::error('Migration('.$migration->getSequence().':'.$migration->getId().') failed, Project('.$this->project->getSequence().':'.$this->project->getId().')');
if ($destination) {
$destination->error();
$sourceErrors = $source?->getErrors() ?? [];
$destinationErrors = $destination?->getErrors() ?? [];
foreach ($destination->getErrors() as $error) {
/** @var MigrationException $error */
call_user_func($this->logError, $error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
foreach ([...$sourceErrors, ...$destinationErrors] as $error) {
/** @var MigrationException $error */
if ($error->getCode() === 0 || $error->getCode() >= 500) {
($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
'migrationId' => $migration->getId(),
'source' => $migration->getAttribute('source') ?? '',
'destination' => $migration->getAttribute('destination') ?? '',
'resourceName' => $error->getResourceName(),
'resourceGroup' => $error->getResourceGroup()
'resourceGroup' => $error->getResourceGroup(),
]);
}
}
if ($source) {
$source->error();
foreach ($source->getErrors() as $error) {
/** @var MigrationException $error */
call_user_func($this->logError, $error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
'migrationId' => $migration->getId(),
'source' => $migration->getAttribute('source') ?? '',
'destination' => $migration->getAttribute('destination') ?? '',
'resourceName' => $error->getResourceName(),
'resourceGroup' => $error->getResourceGroup()
]);
}
}
$source?->error();
$destination?->error();
}
if ($migration->getAttribute('status', '') === 'completed') {
$destination?->success();
$source?->success();
if ($migration->getAttribute('destination') === DestinationCSV::getName()) {
$this->handleCSVExportComplete($project, $migration, $queueForMails);
}
}
}
}
/**
* Handle actions to be performed when a CSV export migration is successfully completed
*
* @param Document $project
* @param Document $migration
* @param Mail $queueForMails
* @return void
* @throws Authorization
* @throws Structure
* @throws \Utopia\Database\Exception
* @throws Exception
*/
protected function handleCSVExportComplete(
Document $project,
Document $migration,
Mail $queueForMails
): void {
$options = $migration->getAttribute('options', []);
$bucketId = $options['bucketId'] ?? null;
$filename = $options['filename'] ?? 'export_' . \time();
$userInternalId = $options['userInternalId'] ?? '';
$bucket = $this->dbForProject->getDocument('buckets', $bucketId);
if ($bucket->isEmpty()) {
throw new \Exception("Bucket not found: $bucketId");
}
$path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . '.csv');
$size = $this->deviceForFiles->getFileSize($path);
$mime = $this->deviceForFiles->getFileMimeType($path);
$hash = $this->deviceForFiles->getFileHash($path);
$algorithm = Compression::NONE;
$fileId = ID::unique();
$sizeMB = \round($size / (1000 * 1000), 2);
if ($sizeMB > $this->plan['fileSize'] ?? PHP_INT_MAX) {
try {
$this->deviceForFiles->delete($path);
} finally {
$message = "Export file size {$sizeMB}MB exceeds your plan limit.";
$this->dbForProject->updateDocument('migrations', $migration->getId(), $migration->setAttribute(
'errors',
json_encode(['code' => 0, 'message' => $message]),
Document::SET_TYPE_APPEND,
));
$this->sendCSVEmail(
success: false,
project: $project,
userInternalId: $userInternalId,
options: $options,
queueForMails: $queueForMails,
sizeMB: $sizeMB
);
throw new \Exception($message);
}
}
$this->dbForProject->createDocument('bucket_' . $bucket->getSequence(), new Document([
'$id' => $fileId,
'$permissions' => [],
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $filename,
'path' => $path,
'signature' => $hash,
'mimeType' => $mime,
'sizeOriginal' => $size,
'sizeActual' => $size,
'algorithm' => $algorithm,
'comment' => '',
'chunksTotal' => 1,
'chunksUploaded' => 1,
'openSSLVersion' => null,
'openSSLCipher' => null,
'openSSLTag' => null,
'openSSLIV' => null,
'search' => \implode(' ', [$fileId, $filename]),
'metadata' => ['content_type' => $mime]
]));
Console::info("Created file document in bucket: $fileId");
// Generate JWT valid for 1 hour
$maxAge = 60 * 60;
$encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $maxAge, 0);
$jwt = $encoder->encode([
'bucketId' => $bucketId,
'fileId' => $fileId,
'projectId' => $project->getId(),
]);
// Generate download URL with JWT
$endpoint = System::getEnv('_APP_DOMAIN', '');
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'disabled') === 'enabled' ? 'https' : 'http';
$downloadUrl = "{$protocol}://{$endpoint}/v1/storage/buckets/{$bucketId}/files/{$fileId}/push?project={$project->getId()}&jwt={$jwt}";
$this->sendCSVEmail(
success: true,
project: $project,
userInternalId: $userInternalId,
options: $options,
queueForMails: $queueForMails,
downloadUrl: $downloadUrl
);
}
/**
* Send CSV export notification email
*
* @param bool $success Whether the export was successful
* @param Document $project
* @param string $userInternalId Internal ID of the user
* @param array $options Migration options
* @param Mail $queueForMails
* @param string $downloadUrl Download URL for successful exports
* @param float $sizeMB File size in MB for failed exports
* @return void
* @throws \Exception
*/
protected function sendCSVEmail(
bool $success,
Document $project,
string $userInternalId,
array $options,
Mail $queueForMails,
string $downloadUrl = '',
float $sizeMB = 0.0
): void {
if (!($options['notify'] ?? false)) {
return;
}
$user = $this->dbForPlatform->findOne('users', [
Query::equal('$sequence', [$userInternalId])
]);
if ($user->isEmpty()) {
Console::warning("User not found for CSV export notification: $userInternalId");
return;
}
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
$emailType = $success
? 'success'
: 'failure';
// Get localized email content
$subject = $locale->getText("emails.csvExport.{$emailType}.subject");
$preview = $locale->getText("emails.csvExport.{$emailType}.preview");
$hello = $locale->getText("emails.csvExport.{$emailType}.hello");
$body = $locale->getText("emails.csvExport.{$emailType}.body");
$footer = $locale->getText("emails.csvExport.{$emailType}.footer");
$thanks = $locale->getText("emails.csvExport.{$emailType}.thanks");
$signature = $locale->getText("emails.csvExport.{$emailType}.signature");
$buttonText = $success ? $locale->getText("emails.csvExport.{$emailType}.buttonText") : '';
// Build email body using inner template
$message = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-inner-base.tpl')
->setParam('{{body}}', $body, escapeHtml: false)
->setParam('{{hello}}', $hello)
->setParam('{{footer}}', $footer)
->setParam('{{thanks}}', $thanks)
->setParam('{{buttonText}}', $buttonText)
->setParam('{{signature}}', $signature)
->setParam('{{direction}}', $locale->getText('settings.direction'))
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{user}}', $user->getAttribute('name', $user->getAttribute('email')))
->setParam('{{redirect}}', $downloadUrl)
->setParam('{{size}}', $success ? '' : (string)$sizeMB);
$emailBody = $message->render();
$emailVariables = [
'direction' => $locale->getText('settings.direction'),
'project' => $project->getAttribute('name'),
'user' => $user->getAttribute('name', $user->getAttribute('email')),
];
if ($success) {
$emailVariables['redirect'] = $downloadUrl;
} else {
$emailVariables['size'] = (string)$sizeMB;
}
$queueForMails
->setSubject($subject)
->setPreview($preview)
->setBody($emailBody)
->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl')
->setVariables($emailVariables)
->setName($user->getAttribute('name', $user->getAttribute('email')))
->setRecipient($user->getAttribute('email'))
->trigger();
Console::info("CSV export {$emailType} notification email sent to " . $user->getAttribute('email'));
}
/**
* Sanitize a filename to make it filesystem-safe
*
* @param string $filename
* @return string
*/
protected function sanitizeFilename(string $filename): string
{
// Replace problematic characters with underscores
$sanitized = \preg_replace('/[:\/<>"|*?]/', '_', $filename);
$sanitized = \preg_replace('/[^\x20-\x7E]/', '_', $sanitized);
$sanitized = \trim($sanitized);
return empty($sanitized) ? 'export' : $sanitized;
}
/**
* Sanitize migration errors, removing sensitive information like stack traces
*
* @param array $sourceErrors
* @param array $destinationErrors
* @return array
*/
protected function sanitizeErrors(
array $sourceErrors,
array $destinationErrors,
): array {
$errors = [];
foreach ([...$sourceErrors, ...$destinationErrors] as $error) {
$encoded = \json_decode(\json_encode($error), true);
if (\is_array($encoded)) {
if (isset($encoded['trace'])) {
unset($encoded['trace']);
}
$errors[] = \json_encode($encoded);
} else {
$errors[] = \json_encode($error);
}
}
return $errors;
}
}

View file

@ -86,6 +86,12 @@ class Migration extends Model
'default' => [],
'example' => [],
])
->addRule('options', [
'type' => self::TYPE_JSON,
'description' => 'Migration options used during the migration process.',
'default' => [],
'example' => '{"bucketId": "exports", "notify": false}',
])
;
}
@ -117,18 +123,16 @@ class Migration extends Model
}
foreach ($errors as $index => $error) {
$decoded = json_decode($error, true);
$decoded = \json_decode($error, true);
// frontend doesn't need too many details.
if (is_array($decoded)) {
$errors[$index] = json_encode([
'code' => $decoded['code'] ?? 0,
'message' => $decoded['message'] ?? null,
]);
if (\is_array($decoded)) {
if (isset($decoded['trace'])) {
unset($decoded['trace']);
}
$errors[$index] = \json_encode($decoded);
}
}
// errors now only have code and message.
$document->setAttribute('errors', $errors);
return $document;

View file

@ -900,7 +900,7 @@ trait MigrationsBase
/**
* Import documents from a CSV file.
*/
public function testCreateCsvMigration(): void
public function testCreateCSVImport(): void
{
// Make a database
$response = $this->client->call(Client::METHOD_POST, '/databases', [
@ -1194,4 +1194,241 @@ trait MigrationsBase
'x-appwrite-project' => $this->getProject()['$id'],
], $body);
}
/**
* Test CSV export with email notification
*/
public function testCreateCSVExport(): void
{
// Create a database
$database = $this->client->call(Client::METHOD_POST, '/databases', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'databaseId' => ID::unique(),
'name' => 'Test Export Database'
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create a collection
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'collectionId' => ID::unique(),
'name' => 'Test Export Collection',
'permissions' => []
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$collectionId = $collection['body']['$id'];
// Create a simple attribute like the basic test
$name = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'key' => 'name',
'size' => 255,
'required' => true,
]);
$this->assertEquals(202, $name['headers']['status-code']);
// Create a simple attribute like the basic test
$email = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'key' => 'email',
'size' => 255,
'required' => false,
]);
$this->assertEquals(202, $email['headers']['status-code']);
\sleep(3);
// Create sample documents
for ($i = 1; $i <= 10; $i++) {
$doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'documentId' => ID::unique(),
'data' => [
'name' => 'Test User ' . $i,
'email' => 'user' . $i . '@appwrite.io'
]
]);
$this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i);
}
// Verify documents were created
$docs = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]);
$this->assertEquals(200, $docs['headers']['status-code']);
$this->assertEquals(10, $docs['body']['total'], 'Expected 10 documents but got ' . $docs['body']['total']);
// Create a storage bucket for the export
$bucketIdUnique = ID::unique();
$bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'bucketId' => $bucketIdUnique,
'name' => 'Test Export Bucket',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'fileSecurity' => false,
'enabled' => true,
'maximumFileSize' => 10485760, // 10MB
'allowedFileExtensions' => ['csv'],
'compression' => 'none',
'encryption' => false,
'antivirus' => false
]);
$this->assertEquals(201, $bucket['headers']['status-code']);
$bucketId = $bucket['body']['$id'];
// Perform CSV export with notification enabled
$migration = $this->client->call(Client::METHOD_POST, '/migrations/csv/exports', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id']
], $this->getHeaders()), [
'bucketId' => $bucketId,
'resourceId' => $databaseId . ':' . $collectionId,
'filename' => 'test-export',
'columns' => [],
'delimiter' => ',',
'enclosure' => '"',
'escape' => '\\',
'header' => true,
'notify' => true
]);
$this->assertEquals(202, $migration['headers']['status-code']);
$this->assertNotEmpty($migration['body']['$id']);
$migrationId = $migration['body']['$id'];
$this->assertEventually(function () use ($bucketId, $migrationId) {
$response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('finished', $response['body']['stage']);
$this->assertEquals('completed', $response['body']['status']);
$this->assertEquals('Appwrite', $response['body']['source']);
$this->assertEquals('CSV', $response['body']['destination']);
$this->assertEquals($bucketId, $response['body']['options']['bucketId']);
return true;
}, 30000, 500);
// Check that the file was created in the bucket
// Query files by filename
$files = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
], [
'queries' => [
Query::equal('name', ['test-export'])->toString()
]
]);
$this->assertEquals(200, $files['headers']['status-code']);
$this->assertEquals(1, $files['body']['total'], 'Expected exactly one file with name "test-export"');
// Get the exported file
$file = $files['body']['files'][0];
$fileId = $file['$id'];
$this->assertEquals($bucketId, $file['bucketId']);
$this->assertEquals('test-export', $file['name']);
$this->assertEquals('text/csv', $file['mimeType']);
$this->assertGreaterThan(0, $file['sizeOriginal']);
// Download and verify CSV content
$download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', \array_merge([
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $download['headers']['status-code']);
$csvContent = $download['body'];
$lines = explode("\n", trim($csvContent));
$this->assertCount(11, $lines);
$this->assertStringContainsString('$id', $lines[0]);
$this->assertStringContainsString('$permissions', $lines[0]);
$this->assertStringContainsString('$createdAt', $lines[0]);
$this->assertStringContainsString('$updatedAt', $lines[0]);
$this->assertStringContainsString('name', $lines[0]);
$this->assertStringContainsString('email', $lines[0]);
$this->assertStringContainsString('Test User 1', $lines[1]);
$this->assertStringContainsString('user1@appwrite.io', $lines[1]);
// Check that email was sent with download link
$lastEmail = $this->getLastEmail();
$this->assertNotEmpty($lastEmail);
$this->assertEquals('Your CSV export is ready', $lastEmail['subject']);
$this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']);
// Extract download URL from email HTML
\preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $lastEmail['html'], $matches);
$this->assertNotEmpty($matches[1], 'Download URL not found in email');
$downloadUrl = html_entity_decode($matches[1]);
// Parse the URL to extract components
$components = \parse_url($downloadUrl);
$this->assertNotEmpty($components);
\parse_str($components['query'] ?? '', $queryParams);
$this->assertArrayHasKey('jwt', $queryParams, 'JWT not found in download URL');
$this->assertNotEmpty($queryParams['jwt']);
// Test download with JWT
$path = \str_replace('/v1', '', $components['path']);
$downloadWithJwt = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']);
$this->assertEquals(200, $downloadWithJwt['headers']['status-code'], 'Failed to download file with JWT');
$this->assertEquals($csvContent, $downloadWithJwt['body'], 'Downloaded content differs from original');
// Test that download without JWT fails
$downloadWithoutJwt = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download');
$this->assertEquals(404, $downloadWithoutJwt['headers']['status-code'], 'File should not be downloadable without JWT');
$this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, [
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]);
$this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]);
$this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]);
}
}