== A2: an API key with ONLY asset.update, made by the admin (the household's session)
key made: yes (secret never printed)
the admin's own photo: {"message":"Missing required permission: job.create"} [403]
the family member's photo: {"message":"Missing required permission: job.create"} [403]
integrity report with this key: 403
visible to the household in Account settings -> API keys: ['Felhom box repair (A2 spike)']
key deleted: 204
== A3: what does the nightly/"missing" thumbnail run read?
services/queue.service.js:214:        if (config.nightlyTasks.missingThumbnails) {
nightly tasks setting: {'startTime': '00:00', 'databaseCleanup': True, 'missingThumbnails': True, 'clusterNewFaces': True, 'generateMemories': True, 'syncQuotaUsage': True}

== A2 retry: key with asset.update + job.create (+ maintenance for the report)
key made: yes
the admin's own photo:  [204]
the family member's photo: {"message":"Not found or no asset.update access"} [400]
integrity report with this key: 200
key deleted: 204
== A3: the missing-thumbnail query
repositories/asset-job.repository.d.ts
293:    streamForThumbnailJob(options: {
294-        force: boolean | undefined;
295-        fullsizeEnabled: boolean;
296-    }): AsyncIterableIterator<{
297-        id: string;
298-        isEdited: boolean;
299-    } & Partial<Omit<unknown, "id" | "isEdited">>>;
300-    getForMigrationJob(id: string): Promise<{
301-        id: string;
302-        ownerId: string;
303-        files: {
304-            id: string;
305-            type: AssetFileType;
306-            path: string;
307-            isEdited: boolean;
308-        }[];
309-    } | undefined>;
310-    getForGenerateThumbnailJob(id: string): Promise<{
311-        id: string;
312-        type: AssetType;
313-        ownerId: string;
314-        edits: ({
315-            action: import("../dtos/editing.dto").AssetEditAction.Crop;
316-            parameters: {
317-                x: number;

58:    streamForThumbnailJob(options) {
59-        return this.db
60-            .selectFrom('asset')
61-            .select(['asset.id', 'asset.isEdited'])
62-            .where('asset.deletedAt', 'is', null)
63-            .where('asset.visibility', '!=', kysely_1.sql.lit(enum_1.AssetVisibility.Hidden))
64-            .$if(!options.force, (qb) => qb
65-            .innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
66-            .where(({ and, eb, exists, not, or, selectFrom }) => {
67-            const file = (type) => selectFrom('asset_file').whereRef('assetId', '=', 'asset.id').where('type', '=', kysely_1.sql.lit(type));
68-            const conditions = [
69-                not(exists(file(enum_1.AssetFileType.Thumbnail))),
70-                not(exists(file(enum_1.AssetFileType.Preview))),
71-                and([
72-                    eb('asset.isEdited', '=', kysely_1.sql.lit(true)),
73-                    not(exists(file(enum_1.AssetFileType.FullSize).where('asset_file.isEdited', '=', kysely_1.sql.lit(true)))),
74-                ]),
75-                eb('asset.thumbhash', 'is', null),
76-            ];
77-            if (options.fullsizeEnabled) {
78-                const isWebUnsupported = kysely_1.sql.join(Object.keys(mime_types_1.mimeTypes.webUnsupportedImage).map((ext) => kysely_1.sql.lit(`%${ext}`)));
79-                conditions.push(and([
80-                    not(exists(file(enum_1.AssetFileType.FullSize))),
81-                    eb((0, kysely_1.sql) `f_unaccent(asset."originalFileName")`, 'like', (0, kysely_1.sql) `any(array[${isWebUnsupported}]::text[])`),
82-                ]));
83-            }
84-            return or(conditions);

