Merge remote-tracking branch 'origin/main' into lighter_buckets_web

This commit is contained in:
Min Idzelis 2025-04-29 01:35:20 +00:00
commit ffda7364dd
294 changed files with 5446 additions and 1625 deletions

78
.vscode/settings.json vendored
View File

@ -1,45 +1,63 @@
{ {
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[css]": { "[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2, "editor.formatOnSave": true,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2 "editor.tabSize": 2
}, },
"svelte.enable-ts-plugin": true,
"eslint.validate": [
"javascript",
"svelte"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"[dart]": { "[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.selectionHighlight": false, "editor.selectionHighlight": false,
"editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first", "editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets", "editor.tabCompletion": "onlySnippets",
"editor.wordBasedSuggestions": "off", "editor.wordBasedSuggestions": "off"
"editor.defaultFormatter": "Dart-Code.dart-code"
}, },
"cSpell.words": [ "[javascript]": {
"immich" "editor.codeActionsOnSave": {
], "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[svelte]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts", "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart" "*.ts": "${capture}.spec.ts,${capture}.mock.ts"
} },
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
} }

View File

@ -17,6 +17,9 @@ e2e:
prod: prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale: prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans

6
cli/package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.63", "version": "2.2.65",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.63", "version": "2.2.65",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@ -54,7 +54,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.1", "version": "1.132.3",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.63", "version": "2.2.65",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View File

@ -14,14 +14,14 @@ online generators you can use.
2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.) 2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.)
3. Save your selections. Reload the map, and enjoy your custom map style! 3. Save your selections. Reload the map, and enjoy your custom map style!
## Use Maptiler to build a custom style ## Use MapTiler to build a custom style
Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. Customizing the map style can be done easily using MapTiler, if you do not want to write an entire JSON document by hand.
1. Create a free account at https://cloud.maptiler.com 1. Create a free account at https://cloud.maptiler.com
2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there. 2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there.
3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer. 3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer.
4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account. 4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account.
5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>![Maptiler Publication Settings](img/immich_map_styles_publish.webp) 5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. MapTiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>![MapTiler Publication Settings](img/immich_map_styles_publish.webp)
6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay. 6. MapTiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler. 7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to MapTiler.

View File

@ -1,7 +1,7 @@
# Database Queries # Database Queries
:::danger :::danger
Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups. Keep in mind that mucking around in the database might set the Moon on fire. Avoid modifying the database directly when possible, and always have current backups.
::: :::
:::tip :::tip

View File

@ -252,6 +252,13 @@ const milestones: Item[] = [
description: 'Browse your photos and videos in their folder structure inside the mobile app', description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0', release: 'v1.130.0',
}), }),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({ withRelease({
icon: mdiTagFaces, icon: mdiTagFaces,
iconColor: 'teal', iconColor: 'teal',
@ -260,13 +267,6 @@ const milestones: Item[] = [
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.', 'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0', release: 'v1.127.0',
}), }),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({ withRelease({
icon: mdiLinkEdit, icon: mdiLinkEdit,
iconColor: 'crimson', iconColor: 'crimson',

View File

@ -1,4 +1,12 @@
[ [
{
"label": "v1.132.3",
"url": "https://v1.132.3.archive.immich.app"
},
{
"label": "v1.132.2",
"url": "https://v1.132.2.archive.immich.app"
},
{ {
"label": "v1.132.1", "label": "v1.132.1",
"url": "https://v1.132.1.archive.immich.app" "url": "https://v1.132.1.archive.immich.app"

8
e2e/package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.132.1", "version": "1.132.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.132.1", "version": "1.132.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@ -44,7 +44,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.63", "version": "2.2.65",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@ -93,7 +93,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.1", "version": "1.132.3",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.132.1", "version": "1.132.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@ -215,6 +215,19 @@ describe('/admin/users', () => {
const user = await getMyUser({ headers: asBearerAuth(token.accessToken) }); const user = await getMyUser({ headers: asBearerAuth(token.accessToken) });
expect(user).toMatchObject({ email: nonAdmin.userEmail }); expect(user).toMatchObject({ email: nonAdmin.userEmail });
}); });
it('should update the avatar color', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}`)
.send({ avatarColor: 'orange' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatarColor: 'orange' });
const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatarColor: 'orange' });
});
}); });
describe('PUT /admin/users/:id/preferences', () => { describe('PUT /admin/users/:id/preferences', () => {
@ -240,19 +253,6 @@ describe('/admin/users', () => {
expect(after).toMatchObject({ memories: { enabled: false } }); expect(after).toMatchObject({ memories: { enabled: false } });
}); });
it('should update the avatar color', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`)
.send({ avatar: { color: 'orange' } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatar: { color: 'orange' } });
const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatar: { color: 'orange' } });
});
it('should update download archive size', async () => { it('should update download archive size', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`) .put(`/admin/users/${admin.userId}/preferences`)

View File

@ -139,6 +139,19 @@ describe('/users', () => {
profileChangedAt: expect.anything(), profileChangedAt: expect.anything(),
}); });
}); });
it('should update avatar color', async () => {
const { status, body } = await request(app)
.put(`/users/me`)
.send({ avatarColor: 'blue' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatarColor: 'blue' });
const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatarColor: 'blue' });
});
}); });
describe('PUT /users/me/preferences', () => { describe('PUT /users/me/preferences', () => {
@ -158,19 +171,6 @@ describe('/users', () => {
expect(after).toMatchObject({ memories: { enabled: false } }); expect(after).toMatchObject({ memories: { enabled: false } });
}); });
it('should update avatar color', async () => {
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ avatar: { color: 'blue' } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatar: { color: 'blue' } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatar: { color: 'blue' } });
});
it('should require an integer for download archive size', async () => { it('should require an integer for download archive size', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/users/me/preferences`) .put(`/users/me/preferences`)

View File

@ -25,7 +25,7 @@ test.describe('Registration', () => {
// login // login
await expect(page).toHaveTitle(/Login/); await expect(page).toHaveTitle(/Login/);
await page.goto('/auth/login'); await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('admin@immich.app'); await page.getByLabel('Email').fill('admin@immich.app');
await page.getByLabel('Password').fill('password'); await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login' }).click();
@ -59,7 +59,7 @@ test.describe('Registration', () => {
await context.clearCookies(); await context.clearCookies();
// login // login
await page.goto('/auth/login'); await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('password'); await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login' }).click();
@ -72,7 +72,7 @@ test.describe('Registration', () => {
await page.getByRole('button', { name: 'Change password' }).click(); await page.getByRole('button', { name: 'Change password' }).click();
// login with new password // login with new password
await expect(page).toHaveURL('/auth/login'); await expect(page).toHaveURL('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('new-password'); await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login' }).click();

View File

@ -21,23 +21,9 @@ test.describe('Photo Viewer', () => {
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }) => {
// before each test, login as user // before each test, login as user
await utils.setAuthCookies(context, admin.accessToken); await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/photos');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
}); });
test('initially shows a loading spinner', async ({ page }) => {
await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => {
// slow down the request for thumbnail, so spinner has chance to show up
await new Promise((f) => setTimeout(f, 2000));
await route.continue();
});
await page.goto(`/photos/${asset.id}`);
await page.waitForLoadState('load');
// this is the spinner
await page.waitForSelector('svg[role=status]');
await expect(page.getByTestId('loading-spinner')).toBeVisible();
});
test('loads original photo when zoomed', async ({ page }) => { test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');

View File

@ -853,10 +853,12 @@
"failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets", "failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
"failed_to_load_asset": "Failed to load asset", "failed_to_load_asset": "Failed to load asset",
"failed_to_load_assets": "Failed to load assets", "failed_to_load_assets": "Failed to load assets",
"failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people", "failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key", "failed_to_remove_product_key": "Failed to remove product key",
"failed_to_stack_assets": "Failed to stack assets", "failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
"import_path_already_exists": "This import path already exists.", "import_path_already_exists": "This import path already exists.",
"incorrect_email_or_password": "Incorrect email or password", "incorrect_email_or_password": "Incorrect email or password",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
@ -1199,6 +1201,9 @@
"map_settings_only_show_favorites": "Show Favorite Only", "map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_theme_settings": "Map Theme", "map_settings_theme_settings": "Map Theme",
"map_zoom_to_see_photos": "Zoom out to see photos", "map_zoom_to_see_photos": "Zoom out to see photos",
"mark_as_read": "Mark as read",
"mark_all_as_read": "Mark all as read",
"marked_all_as_read": "Marked all as read",
"matches": "Matches", "matches": "Matches",
"media_type": "Media type", "media_type": "Media type",
"memories": "Memories", "memories": "Memories",
@ -1260,6 +1265,7 @@
"no_places": "No places", "no_places": "No places",
"no_results": "No results", "no_results": "No results",
"no_results_description": "Try a synonym or more general keyword", "no_results_description": "Try a synonym or more general keyword",
"no_notifications": "No notifications",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network", "no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"not_in_any_album": "Not in any album", "not_in_any_album": "Not in any album",
"not_selected": "Not selected", "not_selected": "Not selected",

View File

@ -1,25 +1,42 @@
package app.alextran.immich package app.alextran.immich
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest import java.security.MessageDigest
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.* import kotlinx.coroutines.*
import androidx.core.net.toUri
/** /**
* Android plugin for Dart `BackgroundService` * Android plugin for Dart `BackgroundService` and file trash operations
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
*/ */
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null private var context: Context? = null
private var pendingResult: Result? = null
private val permissionRequestCode = 1001
private val trashRequestCode = 1002
private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel") methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this) methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() { private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null) methodChannel?.setMethodCallHandler(null)
methodChannel = null methodChannel = null
fileTrashChannel?.setMethodCallHandler(null)
fileTrashChannel = null
} }
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!! val ctx = context!!
when (call.method) { when (call.method) {
// Existing BackgroundService methods
"enable" -> { "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
} }
} }
// File Trash methods moved from MainActivity
"moveToTrash" -> {
val mediaUrls = call.argument<List<String>>("mediaUrls")
if (mediaUrls != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
moveToTrash(mediaUrls, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
} else {
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
}
}
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
val type = call.argument<Int>("type")
if (fileName != null && type != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
restoreFromTrash(fileName, type, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"requestManageMediaPermission" -> {
if (!hasManageMediaPermission()) {
requestManageMediaPermission(result)
} else {
Log.e("Manage storage permission", "Permission already granted")
result.success(true)
}
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(context!!);
} else {
false
}
}
private fun requestManageMediaPermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
intent.data = "package:${activity.packageName}".toUri()
activity.startActivityForResult(intent, permissionRequestCode)
} else {
result.success(false)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun moveToTrash(mediaUrls: List<String>, result: Result) {
val urisToTrash = mediaUrls.map { it.toUri() }
if (urisToTrash.isEmpty()) {
result.error("INVALID_ARGS", "No valid URIs provided", null)
return
}
toggleTrash(urisToTrash, true, result);
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreFromTrash(name: String, type: Int, result: Result) {
val uri = getTrashedFileUri(name, type)
if (uri == null) {
Log.e("TrashError", "Asset Uri cannot be found obtained")
result.error("TrashError", "Asset Uri cannot be found obtained", null)
return
}
Log.e("FILE_URI", uri.toString())
uri.let { toggleTrash(listOf(it), false, result) }
}
@RequiresApi(Build.VERSION_CODES.R)
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
val activity = activityBinding?.activity
val contentResolver = context?.contentResolver
if (activity == null || contentResolver == null) {
result.error("TrashError", "Activity or ContentResolver not available", null)
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
pendingResult = result // Store for onActivityResult
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null, 0, 0, 0
)
} catch (e: Exception) {
Log.e("TrashError", "Error creating or starting trash request", e)
result.error("TrashError", "Error creating or starting trash request", null)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
val contentResolver = context?.contentResolver ?: return null
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply {
putString(
ContentResolver.QUERY_ARG_SQL_SELECTION,
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
// same order as AssetType from dart
val contentUri = when (type) {
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> queryUri
}
return ContentUris.withAppendedId(contentUri, id)
}
}
return null
}
// ActivityAware implementation
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
// ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == permissionRequestCode) {
val granted = hasManageMediaPermission()
pendingResult?.success(granted)
pendingResult = null
return true
}
if (requestCode == trashRequestCode) {
val approved = resultCode == Activity.RESULT_OK
pendingResult?.success(approved)
pendingResult = null
return true
}
return false
}
} }
private const val TAG = "BackgroundServicePlugin" private const val TAG = "BackgroundServicePlugin"
private const val BUFFER_SIZE = 2 * 1024 * 1024; private const val BUFFER_SIZE = 2 * 1024 * 1024

View File

@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle import androidx.annotation.NonNull
import android.content.Intent
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(BackgroundServicePlugin())
// No need to set up method channel here as it's now handled in the plugin
} }
} }

View File

@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 195, "android.injected.version.code" => 197,
"android.injected.version.name" => "1.132.1", "android.injected.version.name" => "1.132.3",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@ -261,9 +261,11 @@
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1; CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100; LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
}; };
FAC6F88F2D287C890078CB2F = { FAC6F88F2D287C890078CB2F = {
CreatedOnToolsVersion = 16.0; CreatedOnToolsVersion = 16.0;
ProvisioningStyle = Automatic;
}; };
}; };
}; };
@ -541,7 +543,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202; CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -685,7 +687,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202; CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -715,7 +717,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202; CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -748,7 +750,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202; CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -769,6 +771,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -791,7 +794,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202; CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -811,6 +814,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -831,7 +835,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202; CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -851,6 +855,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.132.0</string> <string>1.132.3</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202</string> <string>205</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@ -18,8 +18,11 @@ default_platform(:ios)
platform :ios do platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
enable_automatic_code_signing(
path: "./Runner.xcodeproj",
)
increment_version_number( increment_version_number(
version_number: "1.132.1" version_number: "1.132.3"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@ -65,6 +65,7 @@ enum StoreKey<T> {
// Video settings // Video settings
loadOriginalVideo<bool>._(136), loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff // Experimental stuff
photoManagerCustomFilter<bool>._(1000); photoManagerCustomFilter<bool>._(1000);

View File

@ -0,0 +1,5 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(List<String> mediaUrls);
Future<bool> restoreFromTrash(String fileName, int type);
Future<bool> requestManageMediaPermission();
}

View File

@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
@ -13,7 +12,6 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart'; import 'package:immich_mobile/widgets/common/user_avatar.dart';
@ -357,66 +355,51 @@ class PlacesCollectionCard extends StatelessWidget {
final widthFactor = isTablet ? 0.25 : 0.5; final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0; final size = context.width * widthFactor - 20.0;
return FutureBuilder<(Position?, LocationPermission?)>( return GestureDetector(
future: MapUtils.checkPermAndGetLocation( onTap: () => context.pushRoute(
context: context, PlacesCollectionRoute(
silent: true, currentLocation: null,
),
), ),
builder: (context, snapshot) { child: Column(
var position = snapshot.data?.$1; crossAxisAlignment: CrossAxisAlignment.start,
return GestureDetector( children: [
onTap: () => context.pushRoute( SizedBox(
PlacesCollectionRoute( height: size,
currentLocation: position != null width: size,
? LatLng(position.latitude, position.longitude) child: DecoratedBox(
: null, decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color:
context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode: context.isDarkTheme
? ThemeMode.dark
: ThemeMode.light,
),
),
), ),
), ),
child: Column( Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(8.0),
children: [ child: Text(
SizedBox( 'places'.tr(),
height: size, style: context.textTheme.titleSmall?.copyWith(
width: size, color: context.colorScheme.onSurface,
child: DecoratedBox( fontWeight: FontWeight.w500,
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer
.withAlpha(100),
),
child: IgnorePointer(
child: snapshot.connectionState ==
ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: MapThumbnail(
zoom: 8,
centre: LatLng(
position?.latitude ?? 21.44950,
position?.longitude ?? -157.91959,
),
showAttribution: false,
themeMode: context.isDarkTheme
? ThemeMode.dark
: ThemeMode.light,
),
),
),
), ),
Padding( ),
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
), ),
); ],
}, ),
); );
}, },
); );

View File

@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
} }
}), }),
child: const Text( child: const Text(
'grant_permission', 'continue',
).tr(), ).tr(),
), ),
], ],

View File

@ -23,6 +23,7 @@ enum PendingAction {
assetDelete, assetDelete,
assetUploaded, assetUploaded,
assetHidden, assetHidden,
assetTrash,
} }
class PendingChange { class PendingChange {
@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess); socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete); socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates); socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates); socket.on('on_asset_stack_update', _handleServerUpdates);
@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges); _debounce.run(handlePendingChanges);
} }
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetTrash)
.toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges
.expand((a) => (a.value as List).map((e) => e.toString()))
.toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => trashChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlePendingDeletes() async { Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete) .where((c) => c.action == PendingAction.assetDelete)
@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded(); await _handlePendingUploaded();
await _handlePendingDeletes(); await _handlePendingDeletes();
await _handlingPendingHidden(); await _handlingPendingHidden();
await _handlePendingTrashes();
} }
void _handleOnConfigUpdate(dynamic _) { void _handleOnConfigUpdate(dynamic _) {
@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) => void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data); addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) => void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data); addPendingChange(PendingAction.assetHidden, data);

View File

@ -0,0 +1,25 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider =
Provider((ref) => const LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager {
const LocalFilesManagerRepository();
@override
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await LocalFilesManager.moveToTrash(mediaUrls);
}
@override
Future<bool> restoreFromTrash(String fileName, int type) async {
return await LocalFilesManager.restoreFromTrash(fileName, type);
}
@override
Future<bool> requestManageMediaPermission() async {
return await LocalFilesManager.requestManageMediaPermission();
}
}

View File

@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
0, 0,
), ),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true), loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -16,8 +17,10 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album.repository.dart';
@ -25,8 +28,10 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider), ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider), ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider), ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider), ref.watch(userApiRepositoryProvider),
), ),
@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository; final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex(); final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService'); final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService( SyncService(
this._hashService, this._hashService,
@ -82,6 +91,8 @@ class SyncService {
this._userRepository, this._userRepository,
this._userService, this._userService,
this._eTagRepository, this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository, this._partnerApiRepository,
this._userApiRepository, this._userApiRepository,
); );
@ -238,8 +249,22 @@ class SyncService {
return null; return null;
} }
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
final List<Asset> localAssets = await _assetRepository.getAllLocal();
final List<Asset> matchedAssets = localAssets
.where((asset) => idsToDelete.contains(asset.remoteId))
.toList();
final mediaUrls = await Future.wait(
matchedAssets
.map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)),
);
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
}
/// Deletes remote-only assets, updates merged assets to be local-only /// Deletes remote-only assets, updates merged assets to be local-only
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) { Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
return _assetRepository.transaction(() async { return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId( await _assetRepository.deleteAllByRemoteId(
idsToDelete, idsToDelete,
@ -249,6 +274,12 @@ class SyncService {
idsToDelete, idsToDelete,
state: AssetState.merged, state: AssetState.merged,
); );
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return; if (merged.isEmpty) return;
for (final Asset asset in merged) { for (final Asset asset in merged) {
asset.remoteId = null; asset.remoteId = null;
@ -790,10 +821,43 @@ class SyncService {
return (existing, toUpsert); return (existing, toUpsert);
} }
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
final trashMediaUrls = <String>[];
for (final asset in assetsList) {
if (asset.isTrashed) {
final mediaUrl = await asset.local?.getMediaUrl();
if (mediaUrl == null) {
_log.warning(
"Failed to get media URL for asset ${asset.name} while moving to trash",
);
continue;
}
trashMediaUrls.add(mediaUrl);
} else {
await _localFilesManager.restoreFromTrash(
asset.fileName,
asset.type.index,
);
}
}
if (trashMediaUrls.isNotEmpty) {
await _localFilesManager.moveToTrash(trashMediaUrls);
}
}
/// Inserts or updates the assets in the database with their ExifInfo (if any) /// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async { Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return; if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
try { try {
await _assetRepository.transaction(() async { await _assetRepository.transaction(() async {
await _assetRepository.updateAll(assets); await _assetRepository.updateAll(assets);

View File

@ -0,0 +1,38 @@
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
abstract final class LocalFilesManager {
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(List<String> mediaUrls) async {
try {
return await _channel
.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
} catch (e, s) {
_logger.warning('Error moving file to trash', e, s);
return false;
}
}
static Future<bool> restoreFromTrash(String fileName, int type) async {
try {
return await _channel.invokeMethod(
'restoreFromTrash',
{'fileName': fileName, 'type': type},
);
} catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false;
}
}
static Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
}

View File

@ -3,7 +3,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 10; const int targetVersion = 10;
@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
: (await db.iOSDeviceAssets.where().findAll()) : (await db.iOSDeviceAssets.where().findAll())
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash)) .map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
.toList(); .toList();
final localAssets = (await db.assets
.where() final PermissionState ps = await PhotoManager.requestPermissionExtend();
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)) if (!ps.hasAccess) {
.findAll()) if (kDebugMode) {
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt)) debugPrint(
.toList(); "[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
debugPrint("Device Asset Ids length - ${ids.length}"); );
debugPrint("Local Asset Ids length - ${localAssets.length}"); }
return;
}
List<_DeviceAsset> localAssets = [];
final List<AssetPathEntity> paths =
await PhotoManager.getAssetPathList(onlyAll: true);
if (paths.isEmpty) {
localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map(
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
)
.toList();
} else {
final AssetPathEntity albumWithAll = paths.first;
final int assetCount = await albumWithAll.assetCountAsync;
final List<AssetEntity> allDeviceAssets =
await albumWithAll.getAssetListRange(start: 0, end: assetCount);
localAssets = allDeviceAssets
.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
.toList();
}
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId)); ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId)); localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List<DeviceAssetEntity> toAdd = []; final List<DeviceAssetEntity> toAdd = [];
@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
return false; return false;
}, },
onlyFirst: (deviceAsset) { onlyFirst: (deviceAsset) {
debugPrint( if (kDebugMode) {
'DeviceAsset not found in local assets: ${deviceAsset.assetId}', debugPrint(
); '[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
);
}
}, },
onlySecond: (asset) { onlySecond: (asset) {
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}'); if (kDebugMode) {
debugPrint(
'[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
);
}
}, },
); );
debugPrint("Total number of device assets migrated - ${toAdd.length}");
if (kDebugMode) {
debugPrint(
"[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
);
}
await db.writeTxn(() async { await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd); await db.deviceAssetEntitys.putAll(toAdd);
}); });

View File

@ -97,6 +97,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
); );
if (7 - scaleFactor.value.toInt() != perRow.value) { if (7 - scaleFactor.value.toInt() != perRow.value) {
perRow.value = 7 - scaleFactor.value.toInt(); perRow.value = 7 - scaleFactor.value.toInt();
settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value);
} }
}; };
}), }),

View File

@ -755,7 +755,7 @@ class _MonthTitle extends StatelessWidget {
key: Key("month-$title"), key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 24.0), padding: const EdgeInsets.only(left: 12.0, top: 24.0),
child: Text( child: Text(
title, toBeginningOfSentenceCase(title, context.locale.languageCode),
style: const TextStyle( style: const TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -786,7 +786,7 @@ class _Title extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GroupDividerTitle( return GroupDividerTitle(
text: title, text: toBeginningOfSentenceCase(title, context.locale.languageCode),
multiselectEnabled: selectionActive, multiselectEnabled: selectionActive,
onSelect: () => selectAssets(assets), onSelect: () => selectAssets(assets),
onDeselect: () => deselectAssets(assets), onDeselect: () => deselectAssets(assets),

View File

@ -207,9 +207,27 @@ class LoginForm extends HookConsumerWidget {
} }
String generateRandomString(int length) { String generateRandomString(int length) {
const chars =
'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure(); final random = Random.secure();
return base64Url return String.fromCharCodes(
.encode(List<int>.generate(32, (i) => random.nextInt(256))); Iterable.generate(
length,
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
),
);
}
List<int> randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String randomCodeVerifier() {
return base64Url.encode(randomBytes(42));
} }
Future<String> generatePKCECodeChallenge(String codeVerifier) async { Future<String> generatePKCECodeChallenge(String codeVerifier) async {
@ -223,7 +241,8 @@ class LoginForm extends HookConsumerWidget {
String? oAuthServerUrl; String? oAuthServerUrl;
final state = generateRandomString(32); final state = generateRandomString(32);
final codeVerifier = generateRandomString(64);
final codeVerifier = randomCodeVerifier();
final codeChallenge = await generatePKCECodeChallenge(codeVerifier); final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
try { try {

View File

@ -1,11 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting = final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = final allowSelfSignedSSLCert =
@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
); );
Future<bool> checkAndroidVersion() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 31;
}
return false;
}
final advancedSettings = [ final advancedSettings = [
SettingsSwitchListTile( SettingsSwitchListTile(
enabled: true, enabled: true,
@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(), title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(), subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
), ),
FutureBuilder<bool>(
future: checkAndroidVersion(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref
.read(localFilesManagerRepositoryProvider)
.requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
}
},
);
} else {
return const SizedBox.shrink();
}
},
),
SettingsSliderListTile( SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]), text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId, valueNotifier: levelId,

View File

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.132.1 - API version: 1.132.3
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
@ -145,8 +145,15 @@ Class | Method | HTTP request | Description
*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} |
*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} | *NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} |
*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email | *NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications |
*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} |
*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications |
*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} |
*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications |
*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications |
*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} |
*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |
@ -300,7 +307,6 @@ Class | Method | HTTP request | Description
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md) - [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md) - [AudioCodec](doc//AudioCodec.md)
- [AvatarResponse](doc//AvatarResponse.md)
- [AvatarUpdate](doc//AvatarUpdate.md) - [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md) - [BulkIdsDto](doc//BulkIdsDto.md)
@ -361,6 +367,13 @@ Class | Method | HTTP request | Description
- [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md) - [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md)
- [NotificationCreateDto](doc//NotificationCreateDto.md)
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
- [NotificationDto](doc//NotificationDto.md)
- [NotificationLevel](doc//NotificationLevel.md)
- [NotificationType](doc//NotificationType.md)
- [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md)
- [NotificationUpdateDto](doc//NotificationUpdateDto.md)
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md)

View File

@ -44,6 +44,7 @@ part 'api/jobs_api.dart';
part 'api/libraries_api.dart'; part 'api/libraries_api.dart';
part 'api/map_api.dart'; part 'api/map_api.dart';
part 'api/memories_api.dart'; part 'api/memories_api.dart';
part 'api/notifications_api.dart';
part 'api/notifications_admin_api.dart'; part 'api/notifications_admin_api.dart';
part 'api/o_auth_api.dart'; part 'api/o_auth_api.dart';
part 'api/partners_api.dart'; part 'api/partners_api.dart';
@ -107,7 +108,6 @@ part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart'; part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart'; part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart'; part 'model/audio_codec.dart';
part 'model/avatar_response.dart';
part 'model/avatar_update.dart'; part 'model/avatar_update.dart';
part 'model/bulk_id_response_dto.dart'; part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart'; part 'model/bulk_ids_dto.dart';
@ -168,6 +168,13 @@ part 'model/memory_type.dart';
part 'model/memory_update_dto.dart'; part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart'; part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart'; part 'model/metadata_search_dto.dart';
part 'model/notification_create_dto.dart';
part 'model/notification_delete_all_dto.dart';
part 'model/notification_dto.dart';
part 'model/notification_level.dart';
part 'model/notification_type.dart';
part 'model/notification_update_all_dto.dart';
part 'model/notification_update_dto.dart';
part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_authorize_response_dto.dart';
part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_dto.dart';

View File

@ -16,7 +16,54 @@ class NotificationsAdminApi {
final ApiClient apiClient; final ApiClient apiClient;
/// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response]. /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response].
/// Parameters:
///
/// * [NotificationCreateDto] notificationCreateDto (required):
Future<Response> createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/notifications';
// ignore: prefer_final_locals
Object? postBody = notificationCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [NotificationCreateDto] notificationCreateDto (required):
Future<NotificationDto?> createNotification(NotificationCreateDto notificationCreateDto,) async {
final response = await createNotificationWithHttpInfo(notificationCreateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
}
return null;
}
/// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [String] name (required): /// * [String] name (required):
@ -24,7 +71,7 @@ class NotificationsAdminApi {
/// * [TemplateDto] templateDto (required): /// * [TemplateDto] templateDto (required):
Future<Response> getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async { Future<Response> getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/notifications/admin/templates/{name}' final apiPath = r'/admin/notifications/templates/{name}'
.replaceAll('{name}', name); .replaceAll('{name}', name);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@ -68,13 +115,13 @@ class NotificationsAdminApi {
return null; return null;
} }
/// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response]. /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future<Response> sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { Future<Response> sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/notifications/admin/test-email'; final apiPath = r'/admin/notifications/test-email';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = systemConfigSmtpDto; Object? postBody = systemConfigSmtpDto;

View File

@ -0,0 +1,311 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationsApi {
NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteNotificationWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteNotification(String id,) async {
final response = await deleteNotificationWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'DELETE /notifications' operation and returns the [Response].
/// Parameters:
///
/// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
Future<Response> deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications';
// ignore: prefer_final_locals
Object? postBody = notificationDeleteAllDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
Future<void> deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async {
final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> getNotificationWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<NotificationDto?> getNotification(String id,) async {
final response = await getNotificationWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
}
return null;
}
/// Performs an HTTP 'GET /notifications' operation and returns the [Response].
/// Parameters:
///
/// * [String] id:
///
/// * [NotificationLevel] level:
///
/// * [NotificationType] type:
///
/// * [bool] unread:
Future<Response> getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (level != null) {
queryParams.addAll(_queryParams('', 'level', level));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (unread != null) {
queryParams.addAll(_queryParams('', 'unread', unread));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id:
///
/// * [NotificationLevel] level:
///
/// * [NotificationType] type:
///
/// * [bool] unread:
Future<List<NotificationDto>?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<NotificationDto>') as List)
.cast<NotificationDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'PUT /notifications/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [NotificationUpdateDto] notificationUpdateDto (required):
Future<Response> updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = notificationUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [NotificationUpdateDto] notificationUpdateDto (required):
Future<NotificationDto?> updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async {
final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
}
return null;
}
/// Performs an HTTP 'PUT /notifications' operation and returns the [Response].
/// Parameters:
///
/// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
Future<Response> updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications';
// ignore: prefer_final_locals
Object? postBody = notificationUpdateAllDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
Future<void> updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async {
final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -270,8 +270,6 @@ class ApiClient {
return AssetTypeEnumTypeTransformer().decode(value); return AssetTypeEnumTypeTransformer().decode(value);
case 'AudioCodec': case 'AudioCodec':
return AudioCodecTypeTransformer().decode(value); return AudioCodecTypeTransformer().decode(value);
case 'AvatarResponse':
return AvatarResponse.fromJson(value);
case 'AvatarUpdate': case 'AvatarUpdate':
return AvatarUpdate.fromJson(value); return AvatarUpdate.fromJson(value);
case 'BulkIdResponseDto': case 'BulkIdResponseDto':
@ -392,6 +390,20 @@ class ApiClient {
return MergePersonDto.fromJson(value); return MergePersonDto.fromJson(value);
case 'MetadataSearchDto': case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value); return MetadataSearchDto.fromJson(value);
case 'NotificationCreateDto':
return NotificationCreateDto.fromJson(value);
case 'NotificationDeleteAllDto':
return NotificationDeleteAllDto.fromJson(value);
case 'NotificationDto':
return NotificationDto.fromJson(value);
case 'NotificationLevel':
return NotificationLevelTypeTransformer().decode(value);
case 'NotificationType':
return NotificationTypeTypeTransformer().decode(value);
case 'NotificationUpdateAllDto':
return NotificationUpdateAllDto.fromJson(value);
case 'NotificationUpdateDto':
return NotificationUpdateDto.fromJson(value);
case 'OAuthAuthorizeResponseDto': case 'OAuthAuthorizeResponseDto':
return OAuthAuthorizeResponseDto.fromJson(value); return OAuthAuthorizeResponseDto.fromJson(value);
case 'OAuthCallbackDto': case 'OAuthCallbackDto':

View File

@ -100,6 +100,12 @@ String parameterToString(dynamic value) {
if (value is MemoryType) { if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString(); return MemoryTypeTypeTransformer().encode(value).toString();
} }
if (value is NotificationLevel) {
return NotificationLevelTypeTransformer().encode(value).toString();
}
if (value is NotificationType) {
return NotificationTypeTypeTransformer().encode(value).toString();
}
if (value is PartnerDirection) { if (value is PartnerDirection) {
return PartnerDirectionTypeTransformer().encode(value).toString(); return PartnerDirectionTypeTransformer().encode(value).toString();
} }

View File

@ -0,0 +1,180 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationCreateDto {
/// Returns a new [NotificationCreateDto] instance.
NotificationCreateDto({
this.data,
this.description,
this.level,
this.readAt,
required this.title,
this.type,
required this.userId,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Object? data;
String? description;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
NotificationLevel? level;
DateTime? readAt;
String title;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
NotificationType? type;
String userId;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto &&
other.data == data &&
other.description == description &&
other.level == level &&
other.readAt == readAt &&
other.title == title &&
other.type == type &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(data == null ? 0 : data!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(level == null ? 0 : level!.hashCode) +
(readAt == null ? 0 : readAt!.hashCode) +
(title.hashCode) +
(type == null ? 0 : type!.hashCode) +
(userId.hashCode);
@override
String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.data != null) {
json[r'data'] = this.data;
} else {
// json[r'data'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
if (this.level != null) {
json[r'level'] = this.level;
} else {
// json[r'level'] = null;
}
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
json[r'title'] = this.title;
if (this.type != null) {
json[r'type'] = this.type;
} else {
// json[r'type'] = null;
}
json[r'userId'] = this.userId;
return json;
}
/// Returns a new [NotificationCreateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationCreateDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationCreateDto(
data: mapValueOfType<Object>(json, r'data'),
description: mapValueOfType<String>(json, r'description'),
level: NotificationLevel.fromJson(json[r'level']),
readAt: mapDateTime(json, r'readAt', r''),
title: mapValueOfType<String>(json, r'title')!,
type: NotificationType.fromJson(json[r'type']),
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<NotificationCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationCreateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationCreateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationCreateDto> mapFromJson(dynamic json) {
final map = <String, NotificationCreateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationCreateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationCreateDto-objects as value to a dart map
static Map<String, List<NotificationCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationCreateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationCreateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'title',
'userId',
};
}

View File

@ -0,0 +1,101 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationDeleteAllDto {
/// Returns a new [NotificationDeleteAllDto] instance.
NotificationDeleteAllDto({
this.ids = const [],
});
List<String> ids;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationDeleteAllDto &&
_deepEquality.equals(other.ids, ids);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode);
@override
String toString() => 'NotificationDeleteAllDto[ids=$ids]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
return json;
}
/// Returns a new [NotificationDeleteAllDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationDeleteAllDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationDeleteAllDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationDeleteAllDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<NotificationDeleteAllDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationDeleteAllDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationDeleteAllDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationDeleteAllDto> mapFromJson(dynamic json) {
final map = <String, NotificationDeleteAllDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationDeleteAllDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationDeleteAllDto-objects as value to a dart map
static Map<String, List<NotificationDeleteAllDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationDeleteAllDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationDeleteAllDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ids',
};
}

View File

@ -0,0 +1,182 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationDto {
/// Returns a new [NotificationDto] instance.
NotificationDto({
required this.createdAt,
this.data,
this.description,
required this.id,
required this.level,
this.readAt,
required this.title,
required this.type,
});
DateTime createdAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Object? data;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? description;
String id;
NotificationLevel level;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? readAt;
String title;
NotificationType type;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationDto &&
other.createdAt == createdAt &&
other.data == data &&
other.description == description &&
other.id == id &&
other.level == level &&
other.readAt == readAt &&
other.title == title &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(createdAt.hashCode) +
(data == null ? 0 : data!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(id.hashCode) +
(level.hashCode) +
(readAt == null ? 0 : readAt!.hashCode) +
(title.hashCode) +
(type.hashCode);
@override
String toString() => 'NotificationDto[createdAt=$createdAt, data=$data, description=$description, id=$id, level=$level, readAt=$readAt, title=$title, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
if (this.data != null) {
json[r'data'] = this.data;
} else {
// json[r'data'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'id'] = this.id;
json[r'level'] = this.level;
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
json[r'title'] = this.title;
json[r'type'] = this.type;
return json;
}
/// Returns a new [NotificationDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationDto(
createdAt: mapDateTime(json, r'createdAt', r'')!,
data: mapValueOfType<Object>(json, r'data'),
description: mapValueOfType<String>(json, r'description'),
id: mapValueOfType<String>(json, r'id')!,
level: NotificationLevel.fromJson(json[r'level'])!,
readAt: mapDateTime(json, r'readAt', r''),
title: mapValueOfType<String>(json, r'title')!,
type: NotificationType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<NotificationDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationDto> mapFromJson(dynamic json) {
final map = <String, NotificationDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationDto-objects as value to a dart map
static Map<String, List<NotificationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'createdAt',
'id',
'level',
'title',
'type',
};
}

View File

@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationLevel {
/// Instantiate a new enum with the provided [value].
const NotificationLevel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const success = NotificationLevel._(r'success');
static const error = NotificationLevel._(r'error');
static const warning = NotificationLevel._(r'warning');
static const info = NotificationLevel._(r'info');
/// List of all possible values in this [enum][NotificationLevel].
static const values = <NotificationLevel>[
success,
error,
warning,
info,
];
static NotificationLevel? fromJson(dynamic value) => NotificationLevelTypeTransformer().decode(value);
static List<NotificationLevel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationLevel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationLevel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [NotificationLevel] to String,
/// and [decode] dynamic data back to [NotificationLevel].
class NotificationLevelTypeTransformer {
factory NotificationLevelTypeTransformer() => _instance ??= const NotificationLevelTypeTransformer._();
const NotificationLevelTypeTransformer._();
String encode(NotificationLevel data) => data.value;
/// Decodes a [dynamic value][data] to a NotificationLevel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
NotificationLevel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'success': return NotificationLevel.success;
case r'error': return NotificationLevel.error;
case r'warning': return NotificationLevel.warning;
case r'info': return NotificationLevel.info;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [NotificationLevelTypeTransformer] instance.
static NotificationLevelTypeTransformer? _instance;
}

View File

@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationType {
/// Instantiate a new enum with the provided [value].
const NotificationType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const jobFailed = NotificationType._(r'JobFailed');
static const backupFailed = NotificationType._(r'BackupFailed');
static const systemMessage = NotificationType._(r'SystemMessage');
static const custom = NotificationType._(r'Custom');
/// List of all possible values in this [enum][NotificationType].
static const values = <NotificationType>[
jobFailed,
backupFailed,
systemMessage,
custom,
];
static NotificationType? fromJson(dynamic value) => NotificationTypeTypeTransformer().decode(value);
static List<NotificationType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [NotificationType] to String,
/// and [decode] dynamic data back to [NotificationType].
class NotificationTypeTypeTransformer {
factory NotificationTypeTypeTransformer() => _instance ??= const NotificationTypeTypeTransformer._();
const NotificationTypeTypeTransformer._();
String encode(NotificationType data) => data.value;
/// Decodes a [dynamic value][data] to a NotificationType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
NotificationType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'JobFailed': return NotificationType.jobFailed;
case r'BackupFailed': return NotificationType.backupFailed;
case r'SystemMessage': return NotificationType.systemMessage;
case r'Custom': return NotificationType.custom;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [NotificationTypeTypeTransformer] instance.
static NotificationTypeTypeTransformer? _instance;
}

View File

@ -0,0 +1,112 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationUpdateAllDto {
/// Returns a new [NotificationUpdateAllDto] instance.
NotificationUpdateAllDto({
this.ids = const [],
this.readAt,
});
List<String> ids;
DateTime? readAt;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateAllDto &&
_deepEquality.equals(other.ids, ids) &&
other.readAt == readAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(readAt == null ? 0 : readAt!.hashCode);
@override
String toString() => 'NotificationUpdateAllDto[ids=$ids, readAt=$readAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
return json;
}
/// Returns a new [NotificationUpdateAllDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationUpdateAllDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationUpdateAllDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationUpdateAllDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
readAt: mapDateTime(json, r'readAt', r''),
);
}
return null;
}
static List<NotificationUpdateAllDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationUpdateAllDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationUpdateAllDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationUpdateAllDto> mapFromJson(dynamic json) {
final map = <String, NotificationUpdateAllDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationUpdateAllDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationUpdateAllDto-objects as value to a dart map
static Map<String, List<NotificationUpdateAllDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationUpdateAllDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationUpdateAllDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ids',
};
}

View File

@ -10,52 +10,56 @@
part of openapi.api; part of openapi.api;
class AvatarResponse { class NotificationUpdateDto {
/// Returns a new [AvatarResponse] instance. /// Returns a new [NotificationUpdateDto] instance.
AvatarResponse({ NotificationUpdateDto({
required this.color, this.readAt,
}); });
UserAvatarColor color; DateTime? readAt;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AvatarResponse && bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateDto &&
other.color == color; other.readAt == readAt;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(color.hashCode); (readAt == null ? 0 : readAt!.hashCode);
@override @override
String toString() => 'AvatarResponse[color=$color]'; String toString() => 'NotificationUpdateDto[readAt=$readAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'color'] = this.color; if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
return json; return json;
} }
/// Returns a new [AvatarResponse] instance and imports its values from /// Returns a new [NotificationUpdateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AvatarResponse? fromJson(dynamic value) { static NotificationUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "AvatarResponse"); upgradeDto(value, "NotificationUpdateDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AvatarResponse( return NotificationUpdateDto(
color: UserAvatarColor.fromJson(json[r'color'])!, readAt: mapDateTime(json, r'readAt', r''),
); );
} }
return null; return null;
} }
static List<AvatarResponse> listFromJson(dynamic json, {bool growable = false,}) { static List<NotificationUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AvatarResponse>[]; final result = <NotificationUpdateDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AvatarResponse.fromJson(row); final value = NotificationUpdateDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@ -64,12 +68,12 @@ class AvatarResponse {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AvatarResponse> mapFromJson(dynamic json) { static Map<String, NotificationUpdateDto> mapFromJson(dynamic json) {
final map = <String, AvatarResponse>{}; final map = <String, NotificationUpdateDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AvatarResponse.fromJson(entry.value); final value = NotificationUpdateDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@ -78,14 +82,14 @@ class AvatarResponse {
return map; return map;
} }
// maps a json object with a list of AvatarResponse-objects as value to a dart map // maps a json object with a list of NotificationUpdateDto-objects as value to a dart map
static Map<String, List<AvatarResponse>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<NotificationUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AvatarResponse>>{}; final map = <String, List<NotificationUpdateDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AvatarResponse.listFromJson(entry.value, growable: growable,); map[entry.key] = NotificationUpdateDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;
@ -93,7 +97,6 @@ class AvatarResponse {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'color',
}; };
} }

View File

@ -66,6 +66,10 @@ class Permission {
static const memoryPeriodRead = Permission._(r'memory.read'); static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update'); static const memoryPeriodUpdate = Permission._(r'memory.update');
static const memoryPeriodDelete = Permission._(r'memory.delete'); static const memoryPeriodDelete = Permission._(r'memory.delete');
static const notificationPeriodCreate = Permission._(r'notification.create');
static const notificationPeriodRead = Permission._(r'notification.read');
static const notificationPeriodUpdate = Permission._(r'notification.update');
static const notificationPeriodDelete = Permission._(r'notification.delete');
static const partnerPeriodCreate = Permission._(r'partner.create'); static const partnerPeriodCreate = Permission._(r'partner.create');
static const partnerPeriodRead = Permission._(r'partner.read'); static const partnerPeriodRead = Permission._(r'partner.read');
static const partnerPeriodUpdate = Permission._(r'partner.update'); static const partnerPeriodUpdate = Permission._(r'partner.update');
@ -147,6 +151,10 @@ class Permission {
memoryPeriodRead, memoryPeriodRead,
memoryPeriodUpdate, memoryPeriodUpdate,
memoryPeriodDelete, memoryPeriodDelete,
notificationPeriodCreate,
notificationPeriodRead,
notificationPeriodUpdate,
notificationPeriodDelete,
partnerPeriodCreate, partnerPeriodCreate,
partnerPeriodRead, partnerPeriodRead,
partnerPeriodUpdate, partnerPeriodUpdate,
@ -263,6 +271,10 @@ class PermissionTypeTransformer {
case r'memory.read': return Permission.memoryPeriodRead; case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate; case r'memory.update': return Permission.memoryPeriodUpdate;
case r'memory.delete': return Permission.memoryPeriodDelete; case r'memory.delete': return Permission.memoryPeriodDelete;
case r'notification.create': return Permission.notificationPeriodCreate;
case r'notification.read': return Permission.notificationPeriodRead;
case r'notification.update': return Permission.notificationPeriodUpdate;
case r'notification.delete': return Permission.notificationPeriodDelete;
case r'partner.create': return Permission.partnerPeriodCreate; case r'partner.create': return Permission.partnerPeriodCreate;
case r'partner.read': return Permission.partnerPeriodRead; case r'partner.read': return Permission.partnerPeriodRead;
case r'partner.update': return Permission.partnerPeriodUpdate; case r'partner.update': return Permission.partnerPeriodUpdate;

View File

@ -13,6 +13,7 @@ part of openapi.api;
class UserAdminCreateDto { class UserAdminCreateDto {
/// Returns a new [UserAdminCreateDto] instance. /// Returns a new [UserAdminCreateDto] instance.
UserAdminCreateDto({ UserAdminCreateDto({
this.avatarColor,
required this.email, required this.email,
required this.name, required this.name,
this.notify, this.notify,
@ -22,6 +23,8 @@ class UserAdminCreateDto {
this.storageLabel, this.storageLabel,
}); });
UserAvatarColor? avatarColor;
String email; String email;
String name; String name;
@ -51,6 +54,7 @@ class UserAdminCreateDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto && bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto &&
other.avatarColor == avatarColor &&
other.email == email && other.email == email &&
other.name == name && other.name == name &&
other.notify == notify && other.notify == notify &&
@ -62,6 +66,7 @@ class UserAdminCreateDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(email.hashCode) + (email.hashCode) +
(name.hashCode) + (name.hashCode) +
(notify == null ? 0 : notify!.hashCode) + (notify == null ? 0 : notify!.hashCode) +
@ -71,10 +76,15 @@ class UserAdminCreateDto {
(storageLabel == null ? 0 : storageLabel!.hashCode); (storageLabel == null ? 0 : storageLabel!.hashCode);
@override @override
String toString() => 'UserAdminCreateDto[email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
json[r'email'] = this.email; json[r'email'] = this.email;
json[r'name'] = this.name; json[r'name'] = this.name;
if (this.notify != null) { if (this.notify != null) {
@ -110,6 +120,7 @@ class UserAdminCreateDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return UserAdminCreateDto( return UserAdminCreateDto(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
email: mapValueOfType<String>(json, r'email')!, email: mapValueOfType<String>(json, r'email')!,
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
notify: mapValueOfType<bool>(json, r'notify'), notify: mapValueOfType<bool>(json, r'notify'),

View File

@ -13,6 +13,7 @@ part of openapi.api;
class UserAdminUpdateDto { class UserAdminUpdateDto {
/// Returns a new [UserAdminUpdateDto] instance. /// Returns a new [UserAdminUpdateDto] instance.
UserAdminUpdateDto({ UserAdminUpdateDto({
this.avatarColor,
this.email, this.email,
this.name, this.name,
this.password, this.password,
@ -21,6 +22,8 @@ class UserAdminUpdateDto {
this.storageLabel, this.storageLabel,
}); });
UserAvatarColor? avatarColor;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
@ -60,6 +63,7 @@ class UserAdminUpdateDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto && bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto &&
other.avatarColor == avatarColor &&
other.email == email && other.email == email &&
other.name == name && other.name == name &&
other.password == password && other.password == password &&
@ -70,6 +74,7 @@ class UserAdminUpdateDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(email == null ? 0 : email!.hashCode) + (email == null ? 0 : email!.hashCode) +
(name == null ? 0 : name!.hashCode) + (name == null ? 0 : name!.hashCode) +
(password == null ? 0 : password!.hashCode) + (password == null ? 0 : password!.hashCode) +
@ -78,10 +83,15 @@ class UserAdminUpdateDto {
(storageLabel == null ? 0 : storageLabel!.hashCode); (storageLabel == null ? 0 : storageLabel!.hashCode);
@override @override
String toString() => 'UserAdminUpdateDto[email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
if (this.email != null) { if (this.email != null) {
json[r'email'] = this.email; json[r'email'] = this.email;
} else { } else {
@ -124,6 +134,7 @@ class UserAdminUpdateDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return UserAdminUpdateDto( return UserAdminUpdateDto(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
email: mapValueOfType<String>(json, r'email'), email: mapValueOfType<String>(json, r'email'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
password: mapValueOfType<String>(json, r'password'), password: mapValueOfType<String>(json, r'password'),

View File

@ -13,7 +13,6 @@ part of openapi.api;
class UserPreferencesResponseDto { class UserPreferencesResponseDto {
/// Returns a new [UserPreferencesResponseDto] instance. /// Returns a new [UserPreferencesResponseDto] instance.
UserPreferencesResponseDto({ UserPreferencesResponseDto({
required this.avatar,
required this.download, required this.download,
required this.emailNotifications, required this.emailNotifications,
required this.folders, required this.folders,
@ -25,8 +24,6 @@ class UserPreferencesResponseDto {
required this.tags, required this.tags,
}); });
AvatarResponse avatar;
DownloadResponse download; DownloadResponse download;
EmailNotificationsResponse emailNotifications; EmailNotificationsResponse emailNotifications;
@ -47,7 +44,6 @@ class UserPreferencesResponseDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto &&
other.avatar == avatar &&
other.download == download && other.download == download &&
other.emailNotifications == emailNotifications && other.emailNotifications == emailNotifications &&
other.folders == folders && other.folders == folders &&
@ -61,7 +57,6 @@ class UserPreferencesResponseDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(avatar.hashCode) +
(download.hashCode) + (download.hashCode) +
(emailNotifications.hashCode) + (emailNotifications.hashCode) +
(folders.hashCode) + (folders.hashCode) +
@ -73,11 +68,10 @@ class UserPreferencesResponseDto {
(tags.hashCode); (tags.hashCode);
@override @override
String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; String toString() => 'UserPreferencesResponseDto[download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'avatar'] = this.avatar;
json[r'download'] = this.download; json[r'download'] = this.download;
json[r'emailNotifications'] = this.emailNotifications; json[r'emailNotifications'] = this.emailNotifications;
json[r'folders'] = this.folders; json[r'folders'] = this.folders;
@ -99,7 +93,6 @@ class UserPreferencesResponseDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return UserPreferencesResponseDto( return UserPreferencesResponseDto(
avatar: AvatarResponse.fromJson(json[r'avatar'])!,
download: DownloadResponse.fromJson(json[r'download'])!, download: DownloadResponse.fromJson(json[r'download'])!,
emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!,
folders: FoldersResponse.fromJson(json[r'folders'])!, folders: FoldersResponse.fromJson(json[r'folders'])!,
@ -156,7 +149,6 @@ class UserPreferencesResponseDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'avatar',
'download', 'download',
'emailNotifications', 'emailNotifications',
'folders', 'folders',

View File

@ -13,11 +13,14 @@ part of openapi.api;
class UserUpdateMeDto { class UserUpdateMeDto {
/// Returns a new [UserUpdateMeDto] instance. /// Returns a new [UserUpdateMeDto] instance.
UserUpdateMeDto({ UserUpdateMeDto({
this.avatarColor,
this.email, this.email,
this.name, this.name,
this.password, this.password,
}); });
UserAvatarColor? avatarColor;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
@ -44,6 +47,7 @@ class UserUpdateMeDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is UserUpdateMeDto && bool operator ==(Object other) => identical(this, other) || other is UserUpdateMeDto &&
other.avatarColor == avatarColor &&
other.email == email && other.email == email &&
other.name == name && other.name == name &&
other.password == password; other.password == password;
@ -51,15 +55,21 @@ class UserUpdateMeDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(email == null ? 0 : email!.hashCode) + (email == null ? 0 : email!.hashCode) +
(name == null ? 0 : name!.hashCode) + (name == null ? 0 : name!.hashCode) +
(password == null ? 0 : password!.hashCode); (password == null ? 0 : password!.hashCode);
@override @override
String toString() => 'UserUpdateMeDto[email=$email, name=$name, password=$password]'; String toString() => 'UserUpdateMeDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
if (this.email != null) { if (this.email != null) {
json[r'email'] = this.email; json[r'email'] = this.email;
} else { } else {
@ -87,6 +97,7 @@ class UserUpdateMeDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return UserUpdateMeDto( return UserUpdateMeDto(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
email: mapValueOfType<String>(json, r'email'), email: mapValueOfType<String>(json, r'email'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
password: mapValueOfType<String>(json, r'password'), password: mapValueOfType<String>(json, r'password'),

View File

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.132.1+195 version: 1.132.3+197
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'

View File

@ -60,6 +60,9 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository = final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository(); MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository =
MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository = final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository(); MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository(); final MockUserApiRepository userApiRepository = MockUserApiRepository();
@ -106,6 +109,8 @@ void main() {
userRepository, userRepository,
userService, userService,
eTagRepository, eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository, partnerApiRepository,
userApiRepository, userApiRepository,
); );

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {} class MockAuthRepository extends Mock implements IAuthRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {} class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {} class MockLocalFilesManagerRepository extends Mock
implements ILocalFilesManager {}

View File

@ -1,5 +1,6 @@
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/entity.service.dart';
@ -25,4 +26,6 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {} class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {} class MockBackgroundService extends Mock implements BackgroundService {}

View File

@ -206,6 +206,141 @@
] ]
} }
}, },
"/admin/notifications": {
"post": {
"operationId": "createNotification",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/admin/notifications/templates/{name}": {
"post": {
"operationId": "getNotificationTemplateAdmin",
"parameters": [
{
"name": "name",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/admin/notifications/test-email": {
"post": {
"operationId": "sendTestEmailAdmin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigSmtpDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestEmailResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/admin/users": { "/admin/users": {
"get": { "get": {
"operationId": "searchUsersAdmin", "operationId": "searchUsersAdmin",
@ -3485,15 +3620,224 @@
] ]
} }
}, },
"/notifications/admin/templates/{name}": { "/notifications": {
"post": { "delete": {
"operationId": "getNotificationTemplateAdmin", "operationId": "deleteNotifications",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDeleteAllDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"get": {
"operationId": "getNotifications",
"parameters": [ "parameters": [
{ {
"name": "name", "name": "id",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "level",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/NotificationLevel"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/NotificationType"
}
},
{
"name": "unread",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/NotificationDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"put": {
"operationId": "updateNotifications",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationUpdateAllDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
}
},
"/notifications/{id}": {
"delete": {
"operationId": "deleteNotification",
"parameters": [
{
"name": "id",
"required": true, "required": true,
"in": "path", "in": "path",
"schema": { "schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"get": {
"operationId": "getNotification",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"put": {
"operationId": "updateNotification",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string" "type": "string"
} }
} }
@ -3502,7 +3846,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/TemplateDto" "$ref": "#/components/schemas/NotificationUpdateDto"
} }
} }
}, },
@ -3513,7 +3857,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/TemplateResponseDto" "$ref": "#/components/schemas/NotificationDto"
} }
} }
}, },
@ -3532,49 +3876,7 @@
} }
], ],
"tags": [ "tags": [
"Notifications (Admin)" "Notifications"
]
}
},
"/notifications/admin/test-email": {
"post": {
"operationId": "sendTestEmailAdmin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigSmtpDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestEmailResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
] ]
} }
}, },
@ -7656,7 +7958,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.132.1", "version": "1.132.3",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@ -8884,21 +9186,6 @@
], ],
"type": "string" "type": "string"
}, },
"AvatarResponse": {
"properties": {
"color": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
]
}
},
"required": [
"color"
],
"type": "object"
},
"AvatarUpdate": { "AvatarUpdate": {
"properties": { "properties": {
"color": { "color": {
@ -10341,6 +10628,157 @@
}, },
"type": "object" "type": "object"
}, },
"NotificationCreateDto": {
"properties": {
"data": {
"type": "object"
},
"description": {
"nullable": true,
"type": "string"
},
"level": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationLevel"
}
]
},
"readAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"title": {
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationType"
}
]
},
"userId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"title",
"userId"
],
"type": "object"
},
"NotificationDeleteAllDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"ids"
],
"type": "object"
},
"NotificationDto": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"data": {
"type": "object"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"level": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationLevel"
}
]
},
"readAt": {
"format": "date-time",
"type": "string"
},
"title": {
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationType"
}
]
}
},
"required": [
"createdAt",
"id",
"level",
"title",
"type"
],
"type": "object"
},
"NotificationLevel": {
"enum": [
"success",
"error",
"warning",
"info"
],
"type": "string"
},
"NotificationType": {
"enum": [
"JobFailed",
"BackupFailed",
"SystemMessage",
"Custom"
],
"type": "string"
},
"NotificationUpdateAllDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"readAt": {
"format": "date-time",
"nullable": true,
"type": "string"
}
},
"required": [
"ids"
],
"type": "object"
},
"NotificationUpdateDto": {
"properties": {
"readAt": {
"format": "date-time",
"nullable": true,
"type": "string"
}
},
"type": "object"
},
"OAuthAuthorizeResponseDto": { "OAuthAuthorizeResponseDto": {
"properties": { "properties": {
"url": { "url": {
@ -10615,6 +11053,10 @@
"memory.read", "memory.read",
"memory.update", "memory.update",
"memory.delete", "memory.delete",
"notification.create",
"notification.read",
"notification.update",
"notification.delete",
"partner.create", "partner.create",
"partner.read", "partner.read",
"partner.update", "partner.update",
@ -13621,6 +14063,14 @@
}, },
"UserAdminCreateDto": { "UserAdminCreateDto": {
"properties": { "properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"email": { "email": {
"format": "email", "format": "email",
"type": "string" "type": "string"
@ -13763,6 +14213,14 @@
}, },
"UserAdminUpdateDto": { "UserAdminUpdateDto": {
"properties": { "properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"email": { "email": {
"format": "email", "format": "email",
"type": "string" "type": "string"
@ -13826,9 +14284,6 @@
}, },
"UserPreferencesResponseDto": { "UserPreferencesResponseDto": {
"properties": { "properties": {
"avatar": {
"$ref": "#/components/schemas/AvatarResponse"
},
"download": { "download": {
"$ref": "#/components/schemas/DownloadResponse" "$ref": "#/components/schemas/DownloadResponse"
}, },
@ -13858,7 +14313,6 @@
} }
}, },
"required": [ "required": [
"avatar",
"download", "download",
"emailNotifications", "emailNotifications",
"folders", "folders",
@ -13952,6 +14406,14 @@
}, },
"UserUpdateMeDto": { "UserUpdateMeDto": {
"properties": { "properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"email": { "email": {
"format": "email", "format": "email",
"type": "string" "type": "string"

View File

@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.1", "version": "1.132.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.1", "version": "1.132.3",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"

View File

@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.1", "version": "1.132.3",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.132.1 * 1.132.3
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@ -39,6 +39,48 @@ export type ActivityCreateDto = {
export type ActivityStatisticsResponseDto = { export type ActivityStatisticsResponseDto = {
comments: number; comments: number;
}; };
export type NotificationCreateDto = {
data?: object;
description?: string | null;
level?: NotificationLevel;
readAt?: string | null;
title: string;
"type"?: NotificationType;
userId: string;
};
export type NotificationDto = {
createdAt: string;
data?: object;
description?: string;
id: string;
level: NotificationLevel;
readAt?: string;
title: string;
"type": NotificationType;
};
export type TemplateDto = {
template: string;
};
export type TemplateResponseDto = {
html: string;
name: string;
};
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type TestEmailResponseDto = {
messageId: string;
};
export type UserLicense = { export type UserLicense = {
activatedAt: string; activatedAt: string;
activationKey: string; activationKey: string;
@ -64,6 +106,7 @@ export type UserAdminResponseDto = {
updatedAt: string; updatedAt: string;
}; };
export type UserAdminCreateDto = { export type UserAdminCreateDto = {
avatarColor?: (UserAvatarColor) | null;
email: string; email: string;
name: string; name: string;
notify?: boolean; notify?: boolean;
@ -76,6 +119,7 @@ export type UserAdminDeleteDto = {
force?: boolean; force?: boolean;
}; };
export type UserAdminUpdateDto = { export type UserAdminUpdateDto = {
avatarColor?: (UserAvatarColor) | null;
email?: string; email?: string;
name?: string; name?: string;
password?: string; password?: string;
@ -83,9 +127,6 @@ export type UserAdminUpdateDto = {
shouldChangePassword?: boolean; shouldChangePassword?: boolean;
storageLabel?: string | null; storageLabel?: string | null;
}; };
export type AvatarResponse = {
color: UserAvatarColor;
};
export type DownloadResponse = { export type DownloadResponse = {
archiveSize: number; archiveSize: number;
includeEmbeddedVideos: boolean; includeEmbeddedVideos: boolean;
@ -122,7 +163,6 @@ export type TagsResponse = {
sidebarWeb: boolean; sidebarWeb: boolean;
}; };
export type UserPreferencesResponseDto = { export type UserPreferencesResponseDto = {
avatar: AvatarResponse;
download: DownloadResponse; download: DownloadResponse;
emailNotifications: EmailNotificationsResponse; emailNotifications: EmailNotificationsResponse;
folders: FoldersResponse; folders: FoldersResponse;
@ -663,28 +703,15 @@ export type MemoryUpdateDto = {
memoryAt?: string; memoryAt?: string;
seenAt?: string; seenAt?: string;
}; };
export type TemplateDto = { export type NotificationDeleteAllDto = {
template: string; ids: string[];
}; };
export type TemplateResponseDto = { export type NotificationUpdateAllDto = {
html: string; ids: string[];
name: string; readAt?: string | null;
}; };
export type SystemConfigSmtpTransportDto = { export type NotificationUpdateDto = {
host: string; readAt?: string | null;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type TestEmailResponseDto = {
messageId: string;
}; };
export type OAuthConfigDto = { export type OAuthConfigDto = {
codeChallenge?: string; codeChallenge?: string;
@ -1388,6 +1415,7 @@ export type TrashResponseDto = {
count: number; count: number;
}; };
export type UserUpdateMeDto = { export type UserUpdateMeDto = {
avatarColor?: (UserAvatarColor) | null;
email?: string; email?: string;
name?: string; name?: string;
password?: string; password?: string;
@ -1454,6 +1482,43 @@ export function deleteActivity({ id }: {
method: "DELETE" method: "DELETE"
})); }));
} }
export function createNotification({ notificationCreateDto }: {
notificationCreateDto: NotificationCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: NotificationDto;
}>("/admin/notifications", oazapfts.json({
...opts,
method: "POST",
body: notificationCreateDto
})));
}
export function getNotificationTemplateAdmin({ name, templateDto }: {
name: string;
templateDto: TemplateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TemplateResponseDto;
}>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({
...opts,
method: "POST",
body: templateDto
})));
}
export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TestEmailResponseDto;
}>("/admin/notifications/test-email", oazapfts.json({
...opts,
method: "POST",
body: systemConfigSmtpDto
})));
}
export function searchUsersAdmin({ withDeleted }: { export function searchUsersAdmin({ withDeleted }: {
withDeleted?: boolean; withDeleted?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@ -2322,29 +2387,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: bulkIdsDto body: bulkIdsDto
}))); })));
} }
export function getNotificationTemplateAdmin({ name, templateDto }: { export function deleteNotifications({ notificationDeleteAllDto }: {
name: string; notificationDeleteAllDto: NotificationDeleteAllDto;
templateDto: TemplateDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({
status: 200;
data: TemplateResponseDto;
}>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({
...opts, ...opts,
method: "POST", method: "DELETE",
body: templateDto body: notificationDeleteAllDto
}))); })));
} }
export function sendTestEmailAdmin({ systemConfigSmtpDto }: { export function getNotifications({ id, level, $type, unread }: {
systemConfigSmtpDto: SystemConfigSmtpDto; id?: string;
level?: NotificationLevel;
$type?: NotificationType;
unread?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: TestEmailResponseDto; data: NotificationDto[];
}>("/notifications/admin/test-email", oazapfts.json({ }>(`/notifications${QS.query(QS.explode({
id,
level,
"type": $type,
unread
}))}`, {
...opts
}));
}
export function updateNotifications({ notificationUpdateAllDto }: {
notificationUpdateAllDto: NotificationUpdateAllDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({
...opts, ...opts,
method: "POST", method: "PUT",
body: systemConfigSmtpDto body: notificationUpdateAllDto
})));
}
export function deleteNotification({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function getNotification({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: NotificationDto;
}>(`/notifications/${encodeURIComponent(id)}`, {
...opts
}));
}
export function updateNotification({ id, notificationUpdateDto }: {
id: string;
notificationUpdateDto: NotificationUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: NotificationDto;
}>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: notificationUpdateDto
}))); })));
} }
export function startOAuth({ oAuthConfigDto }: { export function startOAuth({ oAuthConfigDto }: {
@ -3453,6 +3560,18 @@ export enum UserAvatarColor {
Gray = "gray", Gray = "gray",
Amber = "amber" Amber = "amber"
} }
export enum NotificationLevel {
Success = "success",
Error = "error",
Warning = "warning",
Info = "info"
}
export enum NotificationType {
JobFailed = "JobFailed",
BackupFailed = "BackupFailed",
SystemMessage = "SystemMessage",
Custom = "Custom"
}
export enum UserStatus { export enum UserStatus {
Active = "active", Active = "active",
Removing = "removing", Removing = "removing",
@ -3527,6 +3646,10 @@ export enum Permission {
MemoryRead = "memory.read", MemoryRead = "memory.read",
MemoryUpdate = "memory.update", MemoryUpdate = "memory.update",
MemoryDelete = "memory.delete", MemoryDelete = "memory.delete",
NotificationCreate = "notification.create",
NotificationRead = "notification.read",
NotificationUpdate = "notification.update",
NotificationDelete = "notification.delete",
PartnerCreate = "partner.create", PartnerCreate = "partner.create",
PartnerRead = "partner.read", PartnerRead = "partner.read",
PartnerUpdate = "partner.update", PartnerUpdate = "partner.update",

View File

@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.132.1", "version": "1.132.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.132.1", "version": "1.132.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@ -32,6 +32,7 @@
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"compression": "^1.8.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^28.3.1",
@ -83,6 +84,7 @@
"@types/archiver": "^6.0.0", "@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2", "@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21", "@types/fluent-ffmpeg": "^2.1.21",
@ -5009,6 +5011,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/compression": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz",
"integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -7603,6 +7615,60 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz",
"integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.0.2",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.132.1", "version": "1.132.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -57,6 +57,7 @@
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"compression": "^1.8.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^28.3.1",
@ -108,6 +109,7 @@
"@types/archiver": "^6.0.0", "@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2", "@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21", "@types/fluent-ffmpeg": "^2.1.21",

View File

@ -44,7 +44,7 @@ const imports = [
BullModule.registerQueue(...bull.queues), BullModule.registerQueue(...bull.queues),
ClsModule.forRoot(cls.config), ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel), OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)), KyselyModule.forRoot(getKyselyConfig(database.config)),
]; ];
class BaseModule implements OnModuleInit, OnModuleDestroy { class BaseModule implements OnModuleInit, OnModuleDestroy {

View File

@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { getKyselyConfig } from 'src/utils/database'; import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
const main = async () => { const main = async () => {
const command = process.argv[2]; const command = process.argv[2];
@ -56,7 +56,7 @@ const main = async () => {
const getDatabaseClient = () => { const getDatabaseClient = () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
return new Kysely<any>(getKyselyConfig(database.config.kysely)); return new Kysely<any>(getKyselyConfig(database.config));
}; };
const runQuery = async (query: string) => { const runQuery = async (query: string) => {
@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => { const compare = async () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely); const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode(); const source = schemaFromCode();
const target = await schemaFromDatabase(db, {}); const target = await schemaFromDatabase(db, {});

View File

@ -78,7 +78,7 @@ class SqlGenerator {
const moduleFixture = await Test.createTestingModule({ const moduleFixture = await Test.createTestingModule({
imports: [ imports: [
KyselyModule.forRoot({ KyselyModule.forRoot({
...getKyselyConfig(database.config.kysely), ...getKyselyConfig(database.config),
log: (event) => { log: (event) => {
if (event.level === 'query') { if (event.level === 'query') {
this.sqlLogger.logQuery(event.query.sql); this.sqlLogger.logQuery(event.query.sql);

View File

@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller'; import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller'; import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
import { NotificationController } from 'src/controllers/notification.controller';
import { OAuthController } from 'src/controllers/oauth.controller'; import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller'; import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller'; import { PersonController } from 'src/controllers/person.controller';
@ -47,6 +48,7 @@ export const controllers = [
LibraryController, LibraryController,
MapController, MapController,
MemoryController, MemoryController,
NotificationController,
NotificationAdminController, NotificationAdminController,
OAuthController, OAuthController,
PartnerController, PartnerController,

View File

@ -1,16 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; import {
NotificationCreateDto,
NotificationDto,
TemplateDto,
TemplateResponseDto,
TestEmailResponseDto,
} from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/email.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationAdminService } from 'src/services/notification-admin.service';
@ApiTags('Notifications (Admin)') @ApiTags('Notifications (Admin)')
@Controller('notifications/admin') @Controller('admin/notifications')
export class NotificationAdminController { export class NotificationAdminController {
constructor(private service: NotificationService) {} constructor(private service: NotificationAdminService) {}
@Post()
@Authenticated({ admin: true })
createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise<NotificationDto> {
return this.service.create(auth, dto);
}
@Post('test-email') @Post('test-email')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@ -0,0 +1,60 @@
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
NotificationDeleteAllDto,
NotificationDto,
NotificationSearchDto,
NotificationUpdateAllDto,
NotificationUpdateDto,
} from 'src/dtos/notification.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Notifications')
@Controller('notifications')
export class NotificationController {
constructor(private service: NotificationService) {}
@Get()
@Authenticated({ permission: Permission.NOTIFICATION_READ })
getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise<NotificationDto[]> {
return this.service.search(auth, dto);
}
@Put()
@Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.NOTIFICATION_DELETE })
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.NOTIFICATION_READ })
getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<NotificationDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
updateNotification(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: NotificationUpdateDto,
): Promise<NotificationDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.NOTIFICATION_DELETE })
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View File

@ -90,7 +90,7 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
} }
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: ImageFormat) { static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`); return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`);
} }

View File

@ -9,6 +9,7 @@ import {
Permission, Permission,
SharedLinkType, SharedLinkType,
SourceType, SourceType,
UserAvatarColor,
UserStatus, UserStatus,
} from 'src/enum'; } from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types'; import { OnThisDayData, UserMetadataItem } from 'src/types';
@ -122,6 +123,7 @@ export type User = {
id: string; id: string;
name: string; name: string;
email: string; email: string;
avatarColor: UserAvatarColor | null;
profileImagePath: string; profileImagePath: string;
profileChangedAt: Date; profileChangedAt: Date;
}; };
@ -264,7 +266,15 @@ export type AssetFace = {
person?: Person | null; person?: Person | null;
}; };
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
'users.id',
'users.name',
'users.email',
'users.avatarColor',
'users.profileImagePath',
'users.profileChangedAt',
] as const;
export const columns = { export const columns = {
asset: [ asset: [
@ -306,7 +316,7 @@ export const columns = {
'shared_links.password', 'shared_links.password',
], ],
user: userColumns, user: userColumns,
userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'], userWithPrefix: userWithPrefixColumns,
userAdmin: [ userAdmin: [
...userColumns, ...userColumns,
'createdAt', 'createdAt',
@ -323,6 +333,7 @@ export const columns = {
], ],
tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'], tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
syncAsset: [ syncAsset: [
'id', 'id',
'ownerId', 'ownerId',

18
server/src/db.d.ts vendored
View File

@ -11,6 +11,8 @@ import {
AssetStatus, AssetStatus,
AssetType, AssetType,
MemoryType, MemoryType,
NotificationLevel,
NotificationType,
Permission, Permission,
SharedLinkType, SharedLinkType,
SourceType, SourceType,
@ -263,6 +265,21 @@ export interface Memories {
updateId: Generated<string>; updateId: Generated<string>;
} }
export interface Notifications {
id: Generated<string>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
updateId: Generated<string>;
userId: string;
level: Generated<NotificationLevel>;
type: NotificationType;
title: string;
description: string | null;
data: any | null;
readAt: Timestamp | null;
}
export interface MemoriesAssetsAssets { export interface MemoriesAssetsAssets {
assetsId: string; assetsId: string;
memoriesId: string; memoriesId: string;
@ -463,6 +480,7 @@ export interface DB {
memories: Memories; memories: Memories;
memories_assets_assets: MemoriesAssetsAssets; memories_assets_assets: MemoriesAssetsAssets;
migrations: Migrations; migrations: Migrations;
notifications: Notifications;
move_history: MoveHistory; move_history: MoveHistory;
naturalearth_countries: NaturalearthCountries; naturalearth_countries: NaturalearthCountries;
partners_audit: PartnersAudit; partners_audit: PartnersAudit;

View File

@ -1,4 +1,7 @@
import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator';
import { NotificationLevel, NotificationType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
export class TestEmailResponseDto { export class TestEmailResponseDto {
messageId!: string; messageId!: string;
@ -11,3 +14,106 @@ export class TemplateDto {
@IsString() @IsString()
template!: string; template!: string;
} }
export class NotificationDto {
id!: string;
@ValidateDate()
createdAt!: Date;
@ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
level!: NotificationLevel;
@ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
type!: NotificationType;
title!: string;
description?: string;
data?: any;
readAt?: Date;
}
export class NotificationSearchDto {
@Optional()
@ValidateUUID({ optional: true })
id?: string;
@IsEnum(NotificationLevel)
@Optional()
@ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
level?: NotificationLevel;
@IsEnum(NotificationType)
@Optional()
@ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
type?: NotificationType;
@ValidateBoolean({ optional: true })
unread?: boolean;
}
export class NotificationCreateDto {
@Optional()
@IsEnum(NotificationLevel)
@ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
level?: NotificationLevel;
@IsEnum(NotificationType)
@Optional()
@ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
type?: NotificationType;
@IsString()
title!: string;
@IsString()
@Optional({ nullable: true })
description?: string | null;
@Optional({ nullable: true })
data?: any;
@ValidateDate({ optional: true, nullable: true })
readAt?: Date | null;
@ValidateUUID()
userId!: string;
}
export class NotificationUpdateDto {
@ValidateDate({ optional: true, nullable: true })
readAt?: Date | null;
}
export class NotificationUpdateAllDto {
@ValidateUUID({ each: true, optional: true })
ids!: string[];
@ValidateDate({ optional: true, nullable: true })
readAt?: Date | null;
}
export class NotificationDeleteAllDto {
@ValidateUUID({ each: true })
ids!: string[];
}
export type MapNotification = {
id: string;
createdAt: Date;
updateId?: string;
level: NotificationLevel;
type: NotificationType;
data: any | null;
title: string;
description: string | null;
readAt: Date | null;
};
export const mapNotification = (notification: MapNotification): NotificationDto => {
return {
id: notification.id,
createdAt: notification.createdAt,
level: notification.level,
type: notification.type,
title: notification.title,
description: notification.description ?? undefined,
data: notification.data ?? undefined,
readAt: notification.readAt ?? undefined,
};
};

View File

@ -137,11 +137,6 @@ export class UserPreferencesUpdateDto {
purchase?: PurchaseUpdate; purchase?: PurchaseUpdate;
} }
class AvatarResponse {
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
color!: UserAvatarColor;
}
class RatingsResponse { class RatingsResponse {
enabled: boolean = false; enabled: boolean = false;
} }
@ -195,7 +190,6 @@ export class UserPreferencesResponseDto implements UserPreferences {
ratings!: RatingsResponse; ratings!: RatingsResponse;
sharedLinks!: SharedLinksResponse; sharedLinks!: SharedLinksResponse;
tags!: TagsResponse; tags!: TagsResponse;
avatar!: AvatarResponse;
emailNotifications!: EmailNotificationsResponse; emailNotifications!: EmailNotificationsResponse;
download!: DownloadResponse; download!: DownloadResponse;
purchase!: PurchaseResponse; purchase!: PurchaseResponse;

View File

@ -1,10 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database'; import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { getPreferences } from 'src/utils/preferences';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
export class UserUpdateMeDto { export class UserUpdateMeDto {
@ -23,6 +22,11 @@ export class UserUpdateMeDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name?: string; name?: string;
@Optional({ nullable: true })
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor | null;
} }
export class UserResponseDto { export class UserResponseDto {
@ -41,13 +45,21 @@ export class UserLicense {
activatedAt!: Date; activatedAt!: Date;
} }
const emailToAvatarColor = (email: string): UserAvatarColor => {
const values = Object.values(UserAvatarColor);
const randomIndex = Math.floor(
[...email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
);
return values[randomIndex];
};
export const mapUser = (entity: User | UserAdmin): UserResponseDto => { export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
return { return {
id: entity.id, id: entity.id,
email: entity.email, email: entity.email,
name: entity.name, name: entity.name,
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color, avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
profileChangedAt: entity.profileChangedAt, profileChangedAt: entity.profileChangedAt,
}; };
}; };
@ -69,6 +81,11 @@ export class UserAdminCreateDto {
@IsString() @IsString()
name!: string; name!: string;
@Optional({ nullable: true })
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor | null;
@Optional({ nullable: true }) @Optional({ nullable: true })
@IsString() @IsString()
@Transform(toSanitized) @Transform(toSanitized)
@ -104,6 +121,11 @@ export class UserAdminUpdateDto {
@IsNotEmpty() @IsNotEmpty()
name?: string; name?: string;
@Optional({ nullable: true })
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor | null;
@Optional({ nullable: true }) @Optional({ nullable: true })
@IsString() @IsString()
@Transform(toSanitized) @Transform(toSanitized)

View File

@ -126,6 +126,11 @@ export enum Permission {
MEMORY_UPDATE = 'memory.update', MEMORY_UPDATE = 'memory.update',
MEMORY_DELETE = 'memory.delete', MEMORY_DELETE = 'memory.delete',
NOTIFICATION_CREATE = 'notification.create',
NOTIFICATION_READ = 'notification.read',
NOTIFICATION_UPDATE = 'notification.update',
NOTIFICATION_DELETE = 'notification.delete',
PARTNER_CREATE = 'partner.create', PARTNER_CREATE = 'partner.create',
PARTNER_READ = 'partner.read', PARTNER_READ = 'partner.read',
PARTNER_UPDATE = 'partner.update', PARTNER_UPDATE = 'partner.update',
@ -332,6 +337,11 @@ export enum ImageFormat {
WEBP = 'webp', WEBP = 'webp',
} }
export enum RawExtractedFormat {
JPEG = 'jpeg',
JXL = 'jxl',
}
export enum LogLevel { export enum LogLevel {
VERBOSE = 'verbose', VERBOSE = 'verbose',
DEBUG = 'debug', DEBUG = 'debug',
@ -515,6 +525,7 @@ export enum JobName {
NOTIFY_SIGNUP = 'notify-signup', NOTIFY_SIGNUP = 'notify-signup',
NOTIFY_ALBUM_INVITE = 'notify-album-invite', NOTIFY_ALBUM_INVITE = 'notify-album-invite',
NOTIFY_ALBUM_UPDATE = 'notify-album-update', NOTIFY_ALBUM_UPDATE = 'notify-album-update',
NOTIFICATIONS_CLEANUP = 'notifications-cleanup',
SEND_EMAIL = 'notification-send-email', SEND_EMAIL = 'notification-send-email',
// Version check // Version check
@ -580,3 +591,17 @@ export enum SyncEntityType {
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
PartnerAssetExifV1 = 'PartnerAssetExifV1', PartnerAssetExifV1 = 'PartnerAssetExifV1',
} }
export enum NotificationLevel {
Success = 'success',
Error = 'error',
Warning = 'warning',
Info = 'info',
}
export enum NotificationType {
JobFailed = 'JobFailed',
BackupFailed = 'BackupFailed',
SystemMessage = 'SystemMessage',
Custom = 'Custom',
}

View File

@ -157,6 +157,15 @@ where
and "memories"."ownerId" = $2 and "memories"."ownerId" = $2
and "memories"."deletedAt" is null and "memories"."deletedAt" is null
-- AccessRepository.notification.checkOwnerAccess
select
"notifications"."id"
from
"notifications"
where
"notifications"."id" in ($1)
and "notifications"."userId" = $2
-- AccessRepository.person.checkOwnerAccess -- AccessRepository.person.checkOwnerAccess
select select
"person"."id" "person"."id"

View File

@ -13,6 +13,7 @@ from
"users"."id", "users"."id",
"users"."name", "users"."name",
"users"."email", "users"."email",
"users"."avatarColor",
"users"."profileImagePath", "users"."profileImagePath",
"users"."profileChangedAt" "users"."profileChangedAt"
from from
@ -44,6 +45,7 @@ returning
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from

View File

@ -12,6 +12,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -36,6 +37,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -100,6 +102,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -124,6 +127,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -191,6 +195,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -215,6 +220,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -269,6 +275,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -292,6 +299,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -353,6 +361,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from

View File

@ -259,6 +259,130 @@ from
where where
"assets"."id" = $2 "assets"."id" = $2
-- AssetJobRepository.getForSyncAssets
select
"assets"."id",
"assets"."isOffline",
"assets"."libraryId",
"assets"."originalPath",
"assets"."status",
"assets"."fileModifiedAt"
from
"assets"
where
"assets"."id" = any ($1::uuid[])
-- AssetJobRepository.getForAssetDeletion
select
"assets"."id",
"assets"."isVisible",
"assets"."libraryId",
"assets"."ownerId",
"assets"."livePhotoVideoId",
"assets"."sidecarPath",
"assets"."encodedVideoPath",
"assets"."originalPath",
to_json("exif") as "exifInfo",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_faces".*,
"person" as "person"
from
"asset_faces"
left join lateral (
select
"person".*
from
"person"
where
"asset_faces"."personId" = "person"."id"
) as "person" on true
where
"asset_faces"."assetId" = "assets"."id"
and "asset_faces"."deletedAt" is null
) as agg
) as "faces",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_files"."id",
"asset_files"."path",
"asset_files"."type"
from
"asset_files"
where
"asset_files"."assetId" = "assets"."id"
) as agg
) as "files",
to_json("stacked_assets") as "stack"
from
"assets"
left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral (
select
"asset_stack"."id",
"asset_stack"."primaryAssetId",
array_agg("stacked") as "assets"
from
"assets" as "stacked"
where
"stacked"."deletedAt" is not null
and "stacked"."isArchived" = $1
and "stacked"."stackId" = "asset_stack"."id"
group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where
"assets"."id" = $2
-- AssetJobRepository.streamForVideoConversion
select
"assets"."id"
from
"assets"
where
"assets"."type" = $1
and (
"assets"."encodedVideoPath" is null
or "assets"."encodedVideoPath" = $2
)
and "assets"."isVisible" = $3
and "assets"."deletedAt" is null
-- AssetJobRepository.getForVideoConversion
select
"assets"."id",
"assets"."ownerId",
"assets"."originalPath",
"assets"."encodedVideoPath"
from
"assets"
where
"assets"."id" = $1
and "assets"."type" = $2
-- AssetJobRepository.streamForMetadataExtraction
select
"assets"."id"
from
"assets"
left join "asset_job_status" on "asset_job_status"."assetId" = "assets"."id"
where
(
"asset_job_status"."metadataExtractedAt" is null
or "asset_job_status"."assetId" is null
)
and "assets"."isVisible" = $1
and "assets"."deletedAt" is null
-- AssetJobRepository.getForStorageTemplateJob -- AssetJobRepository.getForStorageTemplateJob
select select
"assets"."id", "assets"."id",

View File

@ -0,0 +1,58 @@
-- NOTE: This file is auto generated by ./sql-generator
-- NotificationRepository.cleanup
delete from "notifications"
where
(
(
"deletedAt" is not null
and "deletedAt" < $1
)
or (
"readAt" > $2
and "createdAt" < $3
)
or (
"readAt" = $4
and "createdAt" < $5
)
)
-- NotificationRepository.search
select
"id",
"createdAt",
"level",
"type",
"title",
"description",
"data",
"readAt"
from
"notifications"
where
"userId" = $1
and "deletedAt" is null
order by
"createdAt" desc
-- NotificationRepository.search (unread)
select
"id",
"createdAt",
"level",
"type",
"title",
"description",
"data",
"readAt"
from
"notifications"
where
(
"userId" = $1
and "readAt" is null
)
and "deletedAt" is null
order by
"createdAt" desc

View File

@ -12,6 +12,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -29,6 +30,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -61,6 +63,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -78,6 +81,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -112,6 +116,7 @@ returning
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -129,6 +134,7 @@ returning
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -156,6 +162,7 @@ returning
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from
@ -173,6 +180,7 @@ returning
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt" "profileChangedAt"
from from

View File

@ -5,6 +5,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",
@ -43,6 +44,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",
@ -90,6 +92,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",
@ -128,6 +131,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",
@ -152,6 +156,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",
@ -198,6 +203,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",
@ -235,6 +241,7 @@ select
"id", "id",
"name", "name",
"email", "email",
"avatarColor",
"profileImagePath", "profileImagePath",
"profileChangedAt", "profileChangedAt",
"createdAt", "createdAt",

View File

@ -279,6 +279,26 @@ class AuthDeviceAccess {
} }
} }
class NotificationAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, notificationIds: Set<string>) {
if (notificationIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('notifications')
.select('notifications.id')
.where('notifications.id', 'in', [...notificationIds])
.where('notifications.userId', '=', userId)
.execute()
.then((stacks) => new Set(stacks.map((stack) => stack.id)));
}
}
class StackAccess { class StackAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@ -426,6 +446,7 @@ export class AccessRepository {
asset: AssetAccess; asset: AssetAccess;
authDevice: AuthDeviceAccess; authDevice: AuthDeviceAccess;
memory: MemoryAccess; memory: MemoryAccess;
notification: NotificationAccess;
person: PersonAccess; person: PersonAccess;
partner: PartnerAccess; partner: PartnerAccess;
stack: StackAccess; stack: StackAccess;
@ -438,6 +459,7 @@ export class AccessRepository {
this.asset = new AssetAccess(db); this.asset = new AssetAccess(db);
this.authDevice = new AuthDeviceAccess(db); this.authDevice = new AuthDeviceAccess(db);
this.memory = new MemoryAccess(db); this.memory = new MemoryAccess(db);
this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db); this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db); this.partner = new PartnerAccess(db);
this.stack = new StackAccess(db); this.stack = new StackAccess(db);

View File

@ -2,12 +2,21 @@ import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { Asset, columns } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType } from 'src/enum'; import { AssetFileType, AssetType } from 'src/enum';
import { StorageAsset } from 'src/types'; import { StorageAsset } from 'src/types';
import { anyUuid, asUuid, withExifInner, withFaces, withFiles } from 'src/utils/database'; import {
anyUuid,
asUuid,
toJson,
withExif,
withExifInner,
withFaces,
withFacesAndPeople,
withFiles,
} from 'src/utils/database';
@Injectable() @Injectable()
export class AssetJobRepository { export class AssetJobRepository {
@ -148,6 +157,7 @@ export class AssetJobRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
@GenerateSql({ params: [[DummyValue.UUID]] })
getForSyncAssets(ids: string[]) { getForSyncAssets(ids: string[]) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
@ -163,6 +173,84 @@ export class AssetJobRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
getForAssetDeletion(id: string) {
return this.db
.selectFrom('assets')
.select([
'assets.id',
'assets.isVisible',
'assets.libraryId',
'assets.ownerId',
'assets.livePhotoVideoId',
'assets.sidecarPath',
'assets.encodedVideoPath',
'assets.originalPath',
])
.$call(withExif)
.select(withFacesAndPeople)
.select(withFiles)
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets as stacked')
.select(['asset_stack.id', 'asset_stack.primaryAssetId'])
.select((eb) => eb.fn<Asset[]>('array_agg', [eb.table('stacked')]).as('assets'))
.where('stacked.deletedAt', 'is not', null)
.where('stacked.isArchived', '=', false)
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.groupBy('asset_stack.id')
.as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null),
)
.select((eb) => toJson(eb, 'stacked_assets').as('stack'))
.where('assets.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForVideoConversion(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.where('assets.type', '=', AssetType.VIDEO)
.$if(!force, (qb) =>
qb
.where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')]))
.where('assets.isVisible', '=', true),
)
.where('assets.deletedAt', 'is', null)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForVideoConversion(id: string) {
return this.db
.selectFrom('assets')
.select(['assets.id', 'assets.ownerId', 'assets.originalPath', 'assets.encodedVideoPath'])
.where('assets.id', '=', id)
.where('assets.type', '=', AssetType.VIDEO)
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForMetadataExtraction(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.$if(!force, (qb) =>
qb
.leftJoin('asset_job_status', 'asset_job_status.assetId', 'assets.id')
.where((eb) =>
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
)
.where('assets.isVisible', '=', true),
)
.where('assets.deletedAt', 'is', null)
.stream();
}
private storageTemplateAssetQuery() { private storageTemplateAssetQuery() {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')

View File

@ -80,21 +80,12 @@ describe('getEnv', () => {
const { database } = getEnv(); const { database } = getEnv();
expect(database).toEqual({ expect(database).toEqual({
config: { config: {
kysely: expect.objectContaining({ connectionType: 'parts',
host: 'database', host: 'database',
port: 5432, port: 5432,
database: 'immich', database: 'immich',
username: 'postgres', username: 'postgres',
password: 'postgres', password: 'postgres',
}),
typeorm: expect.objectContaining({
type: 'postgres',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
}, },
skipMigrations: false, skipMigrations: false,
vectorExtension: 'vectors', vectorExtension: 'vectors',
@ -110,88 +101,9 @@ describe('getEnv', () => {
it('should use DB_URL', () => { it('should use DB_URL', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich'; process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
const { database } = getEnv(); const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ expect(database.config).toMatchObject({
host: 'database1', connectionType: 'url',
password: 'postgres2', url: 'postgres://postgres1:postgres2@database1:54320/immich',
user: 'postgres1',
port: 54_320,
database: 'immich',
});
});
it('should handle sslmode=require', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=prefer', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-ca', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-full', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=no-verify', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
});
it('should handle ssl=true', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: true });
});
it('should reject invalid ssl', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
});
it('should handle socket: URLs', () => {
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/run/postgresql',
database: 'database1',
});
});
it('should handle sockets in postgres: URLs', () => {
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/path/to/socket',
database: 'database2',
}); });
}); });
}); });

View File

@ -7,8 +7,7 @@ import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis'; import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join, resolve } from 'node:path'; import { join } from 'node:path';
import { parse } from 'pg-connection-string';
import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto'; import { EnvDto } from 'src/dtos/env.dto';
@ -22,9 +21,7 @@ import {
QueueName, QueueName,
} from 'src/enum'; } from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
import { setDifference } from 'src/utils/set'; import { setDifference } from 'src/utils/set';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export interface EnvData { export interface EnvData {
host?: string; host?: string;
@ -59,7 +56,7 @@ export interface EnvData {
}; };
database: { database: {
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig }; config: DatabaseConnectionParams;
skipMigrations: boolean; skipMigrations: boolean;
vectorExtension: VectorExtension; vectorExtension: VectorExtension;
}; };
@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION; const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = dto.IMMICH_BUILD_DATA || '/build'; const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
const folders = { const folders = {
// eslint-disable-next-line unicorn/prefer-module
dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'), geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'), web: join(buildFolder, 'www'),
}; };
const databaseUrl = dto.DB_URL;
let redisConfig = { let redisConfig = {
host: dto.REDIS_HOSTNAME || 'redis', host: dto.REDIS_HOSTNAME || 'redis',
port: dto.REDIS_PORT || 6379, port: dto.REDIS_PORT || 6379,
@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
} }
} }
const parts = { const databaseConnection: DatabaseConnectionParams = dto.DB_URL
connectionType: 'parts', ? { connectionType: 'url', url: dto.DB_URL }
host: dto.DB_HOSTNAME || 'database', : {
port: dto.DB_PORT || 5432, connectionType: 'parts',
username: dto.DB_USERNAME || 'postgres', host: dto.DB_HOSTNAME || 'database',
password: dto.DB_PASSWORD || 'postgres', port: dto.DB_PORT || 5432,
database: dto.DB_DATABASE_NAME || 'immich', username: dto.DB_USERNAME || 'postgres',
} as const; password: dto.DB_PASSWORD || 'postgres',
database: dto.DB_DATABASE_NAME || 'immich',
let parsedOptions: PostgresConnectionConfig = parts; };
if (dto.DB_URL) {
const parsed = parse(dto.DB_URL);
if (!isValidSsl(parsed.ssl)) {
throw new Error(`Invalid ssl option: ${parsed.ssl}`);
}
parsedOptions = {
...parsed,
ssl: parsed.ssl,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
};
}
return { return {
host: dto.IMMICH_HOST, host: dto.IMMICH_HOST,
@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
}, },
database: { database: {
config: { config: databaseConnection,
typeorm: {
type: 'postgres',
entities: [],
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
subscribers: [],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
},
kysely: parsedOptions,
},
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS, vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
}, },

View File

@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises'; import { readdir } from 'node:fs/promises';
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import semver from 'semver'; import semver from 'semver';
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db'; import { DB } from 'src/db';
@ -205,8 +205,29 @@ export class DatabaseRepository {
const { rows } = await tableExists.execute(this.db); const { rows } = await tableExists.execute(this.db);
const hasTypeOrmMigrations = !!rows[0]?.result; const hasTypeOrmMigrations = !!rows[0]?.result;
if (hasTypeOrmMigrations) { if (hasTypeOrmMigrations) {
// eslint-disable-next-line unicorn/prefer-module
const dist = resolve(`${__dirname}/..`);
this.logger.debug('Running typeorm migrations'); this.logger.debug('Running typeorm migrations');
const dataSource = new DataSource(database.config.typeorm); const dataSource = new DataSource({
type: 'postgres',
entities: [],
subscribers: [],
migrations: [`${dist}/migrations` + '/*.{js,ts}'],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(database.config.connectionType === 'url'
? { url: database.config.url }
: {
host: database.config.host,
port: database.config.port,
username: database.config.username,
password: database.config.password,
database: database.config.database,
}),
});
await dataSource.initialize(); await dataSource.initialize();
await dataSource.runMigrations(options); await dataSource.runMigrations(options);
await dataSource.destroy(); await dataSource.destroy();

View File

@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config';
import { EventConfig } from 'src/decorators'; import { EventConfig } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
@ -64,6 +65,7 @@ type EventMap = {
'assets.restore': [{ assetIds: string[]; userId: string }]; 'assets.restore': [{ assetIds: string[]; userId: string }];
'job.start': [QueueName, JobItem]; 'job.start': [QueueName, JobItem];
'job.failed': [{ job: JobItem; error: Error | any }];
// session events // session events
'session.delete': [{ sessionId: string }]; 'session.delete': [{ sessionId: string }];
@ -104,6 +106,7 @@ export interface ClientEventMap {
on_server_version: [ServerVersionResponseDto]; on_server_version: [ServerVersionResponseDto];
on_config_update: []; on_config_update: [];
on_new_release: [ReleaseNotification]; on_new_release: [ReleaseNotification];
on_notification: [NotificationDto];
on_session_delete: [string]; on_session_delete: [string];
} }

View File

@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository'; import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository'; import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository';
import { PartnerRepository } from 'src/repositories/partner.repository'; import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository'; import { PersonRepository } from 'src/repositories/person.repository';
@ -55,6 +56,7 @@ export const repositories = [
CryptoRepository, CryptoRepository,
DatabaseRepository, DatabaseRepository,
DownloadRepository, DownloadRepository,
EmailRepository,
EventRepository, EventRepository,
JobRepository, JobRepository,
LibraryRepository, LibraryRepository,
@ -65,7 +67,7 @@ export const repositories = [
MemoryRepository, MemoryRepository,
MetadataRepository, MetadataRepository,
MoveRepository, MoveRepository,
EmailRepository, NotificationRepository,
OAuthRepository, OAuthRepository,
PartnerRepository, PartnerRepository,
PersonRepository, PersonRepository,

View File

@ -7,7 +7,7 @@ import { Writable } from 'node:stream';
import sharp from 'sharp'; import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { Exif } from 'src/database'; import { Exif } from 'src/database';
import { Colorspace, LogLevel } from 'src/enum'; import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { import {
DecodeToBufferOptions, DecodeToBufferOptions,
@ -36,34 +36,51 @@ type ProgressEvent = {
percent?: number; percent?: number;
}; };
export type ExtractResult = {
buffer: Buffer;
format: RawExtractedFormat;
};
@Injectable() @Injectable()
export class MediaRepository { export class MediaRepository {
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {
this.logger.setContext(MediaRepository.name); this.logger.setContext(MediaRepository.name);
} }
async extract(input: string, output: string): Promise<boolean> { /**
*
* @param input file path to the input image
* @returns ExtractResult if succeeded, or null if failed
*/
async extract(input: string): Promise<ExtractResult | null> {
try { try {
// remove existing output file if it exists const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
// as exiftool-vendored does not support overwriting via "-w!" flag return { buffer, format: RawExtractedFormat.JPEG };
// and throws "1 files could not be read" error when the output file exists } catch (error: any) {
await fs.unlink(output).catch(() => null); this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message);
await exiftool.extractBinaryTag('JpgFromRaw2', input, output); }
} catch {
try { try {
this.logger.debug('Extracting JPEG from RAW image:', input); const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
await exiftool.extractJpgFromRaw(input, output); return { buffer, format: RawExtractedFormat.JPEG };
} catch (error: any) { } catch (error: any) {
this.logger.debug('Could not extract JPEG from image, trying preview', error.message); this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message);
try { }
await exiftool.extractPreview(input, output);
} catch (error: any) { try {
this.logger.debug('Could not extract preview from image', error.message); const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return false; return { buffer, format: RawExtractedFormat.JXL };
} } catch (error: any) {
} this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.JPEG };
} catch (error: any) {
this.logger.debug('Could not extract preview buffer from image', error.message);
return null;
} }
return true;
} }
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> { async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
@ -104,7 +121,7 @@ export class MediaRepository {
} }
} }
decodeImage(input: string, options: DecodeToBufferOptions) { decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
} }
@ -235,7 +252,7 @@ export class MediaRepository {
}); });
} }
async getImageDimensions(input: string): Promise<ImageDimensions> { async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
const { width = 0, height = 0 } = await sharp(input).metadata(); const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height }; return { width, height };
} }

View File

@ -0,0 +1,103 @@
import { Insertable, Kysely, Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, Notifications } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { NotificationSearchDto } from 'src/dtos/notification.dto';
export class NotificationRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
cleanup() {
return this.db
.deleteFrom('notifications')
.where((eb) =>
eb.or([
// remove soft-deleted notifications
eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]),
// remove old, read notifications
eb.and([
// keep recently read messages around for a few days
eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()),
eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()),
]),
eb.and([
// remove super old, unread notifications
eb('readAt', '=', null),
eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()),
]),
]),
)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] })
search(userId: string, dto: NotificationSearchDto) {
return this.db
.selectFrom('notifications')
.select(columns.notification)
.where((qb) =>
qb.and({
userId,
id: dto.id,
level: dto.level,
type: dto.type,
readAt: dto.unread ? null : undefined,
}),
)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.execute();
}
create(notification: Insertable<Notifications>) {
return this.db
.insertInto('notifications')
.values(notification)
.returning(columns.notification)
.executeTakeFirstOrThrow();
}
get(id: string) {
return this.db
.selectFrom('notifications')
.select(columns.notification)
.where('id', '=', id)
.where('deletedAt', 'is not', null)
.executeTakeFirst();
}
update(id: string, notification: Updateable<Notifications>) {
return this.db
.updateTable('notifications')
.set(notification)
.where('deletedAt', 'is', null)
.where('id', '=', id)
.returning(columns.notification)
.executeTakeFirstOrThrow();
}
async updateAll(ids: string[], notification: Updateable<Notifications>) {
await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute();
}
async delete(id: string) {
await this.db
.updateTable('notifications')
.set({ deletedAt: DateTime.now().toJSDate() })
.where('id', '=', id)
.execute();
}
async deleteAll(ids: string[]) {
await this.db
.updateTable('notifications')
.set({ deletedAt: DateTime.now().toJSDate() })
.where('id', 'in', ids)
.execute();
}
}

View File

@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table'; import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table'; import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table'; import { PersonTable } from 'src/schema/tables/person.table';
@ -76,6 +77,7 @@ export class ImmichDatabase {
MemoryTable, MemoryTable,
MoveTable, MoveTable,
NaturalEarthCountriesTable, NaturalEarthCountriesTable,
NotificationTable,
PartnerAuditTable, PartnerAuditTable,
PartnerTable, PartnerTable,
PersonTable, PersonTable,

View File

@ -0,0 +1,22 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db);
await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db);
await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at"
BEFORE UPDATE ON "notifications"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db);
await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db);
await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db);
await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db);
await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db);
await sql`DROP TABLE "notifications";`.execute(db);
}

View File

@ -0,0 +1,14 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" ADD "avatarColor" character varying;`.execute(db);
await sql`
UPDATE "users"
SET "avatarColor" = "user_metadata"."value"->'avatar'->>'color'
FROM "user_metadata"
WHERE "users"."id" = "user_metadata"."userId" AND "user_metadata"."key" = 'preferences';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" DROP COLUMN "avatarColor";`.execute(db);
}

View File

@ -0,0 +1,52 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { NotificationLevel, NotificationType } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('notifications')
@UpdatedAtTrigger('notifications_updated_at')
export class NotificationTable {
@PrimaryGeneratedColumn()
id!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@DeleteDateColumn()
deletedAt?: Date;
@UpdateIdColumn({ indexName: 'IDX_notifications_update_id' })
updateId?: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
userId!: string;
@Column({ default: NotificationLevel.Info })
level!: NotificationLevel;
@Column({ default: NotificationLevel.Info })
type!: NotificationType;
@Column({ type: 'jsonb', nullable: true })
data!: any | null;
@Column()
title!: string;
@Column({ type: 'text', nullable: true })
description!: string;
@Column({ type: 'timestamp with time zone', nullable: true })
readAt?: Date | null;
}

View File

@ -1,6 +1,6 @@
import { ColumnType } from 'kysely'; import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserStatus } from 'src/enum'; import { UserAvatarColor, UserStatus } from 'src/enum';
import { users_delete_audit } from 'src/schema/functions'; import { users_delete_audit } from 'src/schema/functions';
import { import {
AfterDeleteTrigger, AfterDeleteTrigger,
@ -49,6 +49,9 @@ export class UserTable {
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>; shouldChangePassword!: Generated<boolean>;
@Column({ default: null })
avatarColor!: UserAvatarColor | null;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt!: Timestamp | null; deletedAt!: Timestamp | null;

View File

@ -565,7 +565,7 @@ describe(AssetService.name, () => {
it('should remove faces', async () => { it('should remove faces', async () => {
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
mocks.asset.getById.mockResolvedValue(assetWithFace); mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetWithFace);
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
@ -592,7 +592,7 @@ describe(AssetService.name, () => {
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
mocks.stack.update.mockResolvedValue(factory.stack() as any); mocks.stack.update.mockResolvedValue(factory.stack() as any);
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.primaryImage);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
@ -604,7 +604,7 @@ describe(AssetService.name, () => {
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
mocks.stack.delete.mockResolvedValue(); mocks.stack.delete.mockResolvedValue();
mocks.asset.getById.mockResolvedValue({ mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...assetStub.primaryImage, ...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
}); });
@ -615,7 +615,7 @@ describe(AssetService.name, () => {
}); });
it('should delete a live photo', async () => { it('should delete a live photo', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any);
mocks.asset.getLivePhotoCount.mockResolvedValue(0); mocks.asset.getLivePhotoCount.mockResolvedValue(0);
await sut.handleAssetDeletion({ await sut.handleAssetDeletion({
@ -653,7 +653,7 @@ describe(AssetService.name, () => {
it('should not delete a live motion part if it is being used by another asset', async () => { it('should not delete a live motion part if it is being used by another asset', async () => {
mocks.asset.getLivePhotoCount.mockResolvedValue(2); mocks.asset.getLivePhotoCount.mockResolvedValue(2);
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any);
await sut.handleAssetDeletion({ await sut.handleAssetDeletion({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
@ -680,12 +680,13 @@ describe(AssetService.name, () => {
}); });
it('should update usage', async () => { it('should update usage', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image);
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
}); });
it('should fail if asset could not be found', async () => { it('should fail if asset could not be found', async () => {
mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0);
await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe(
JobStatus.FAILED, JobStatus.FAILED,
); );

View File

@ -189,13 +189,7 @@ export class AssetService extends BaseService {
async handleAssetDeletion(job: JobOf<JobName.ASSET_DELETION>): Promise<JobStatus> { async handleAssetDeletion(job: JobOf<JobName.ASSET_DELETION>): Promise<JobStatus> {
const { id, deleteOnDisk } = job; const { id, deleteOnDisk } = job;
const asset = await this.assetRepository.getById(id, { const asset = await this.assetJobRepository.getForAssetDeletion(id);
faces: { person: true },
library: true,
stack: { assets: true },
exifInfo: true,
files: true,
});
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;

View File

@ -142,52 +142,55 @@ describe(BackupService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
}); });
it('should run a database backup successfully', async () => { it('should run a database backup successfully', async () => {
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.createWriteStream).toHaveBeenCalled(); expect(mocks.storage.createWriteStream).toHaveBeenCalled();
}); });
it('should rename file on success', async () => { it('should rename file on success', async () => {
const result = await sut.handleBackupDatabase(); const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS); expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.rename).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalled();
}); });
it('should fail if pg_dumpall fails', async () => { it('should fail if pg_dumpall fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase(); await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(result).toBe(JobStatus.FAILED);
}); });
it('should not rename file if pgdump fails and gzip succeeds', async () => { it('should not rename file if pgdump fails and gzip succeeds', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase(); await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(result).toBe(JobStatus.FAILED);
expect(mocks.storage.rename).not.toHaveBeenCalled(); expect(mocks.storage.rename).not.toHaveBeenCalled();
}); });
it('should fail if gzip fails', async () => { it('should fail if gzip fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase(); await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1');
expect(result).toBe(JobStatus.FAILED);
}); });
it('should fail if write stream fails', async () => { it('should fail if write stream fails', async () => {
mocks.storage.createWriteStream.mockImplementation(() => { mocks.storage.createWriteStream.mockImplementation(() => {
throw new Error('error'); throw new Error('error');
}); });
const result = await sut.handleBackupDatabase(); await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
expect(result).toBe(JobStatus.FAILED);
}); });
it('should fail if rename fails', async () => { it('should fail if rename fails', async () => {
mocks.storage.rename.mockRejectedValue(new Error('error')); mocks.storage.rename.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase(); await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
expect(result).toBe(JobStatus.FAILED);
}); });
it('should ignore unlink failing and still return failed job status', async () => { it('should ignore unlink failing and still return failed job status', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
mocks.storage.unlink.mockRejectedValue(new Error('error')); mocks.storage.unlink.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase(); await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(mocks.storage.unlink).toHaveBeenCalled(); expect(mocks.storage.unlink).toHaveBeenCalled();
expect(result).toBe(JobStatus.FAILED);
}); });
it.each` it.each`
postgresVersion | expectedVersion postgresVersion | expectedVersion
${'14.10'} | ${14} ${'14.10'} | ${14}

Some files were not shown because too many files have changed in this diff Show More