feat(api): upload verify, forbid-overwrite copy, idempotent complete
Task 19: POST /uploads/{id}/renew re-signs the SAME staging key for pending
sessions (uploadEnabled=false -> 409 ALBUM_UPLOAD_DISABLED; non-owner/unknown/
removed-member -> 403 AUTH_FORBIDDEN, indistinguishable per contract) and
slides the session expiry — renew is the documented recovery path for an
expired credential. POST /uploads/{id}/complete verifies the staging object
out-of-transaction first (head: missing / byte-count mismatch / contentType
mismatch — the server-side backstop for presign not signing content-type),
then copyIfAbsent's it to the finals key with forbid-overwrite semantics,
then in ONE transaction updates photo(bytes/uploadedAt/uploaded), claims the
session pending->confirmed via conditional updateMany (concurrent completes:
exactly one winner creates the photo_jobs row + upload.complete audit), and
enqueues QUEUE_PHOTO_PROCESS post-commit (jobId derived from photoId for
dedupe; failure only logs — Task 21 sweep compensates on photo_jobs). Verify
failures are request-level 400s: the session stays pending and retryable.
Repeat complete on a confirmed session re-runs copyIfAbsent (created:false,
staging already cleaned is fine) and returns the current processingState —
no second job/audit/enqueue (A03). GET /uploads lists the caller's own
sessions newest-first with (createdAt,id) keyset pagination and the joined
photo processingState/visibility. Both producer and test Queue instances now
quit their caller-owned ioredis connection (BullMQ does not take ownership),
otherwise jest hangs on open handles.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -30,21 +30,22 @@ export class BullMqPhotoProcessQueue
|
||||
{
|
||||
private readonly logger = new Logger(BullMqPhotoProcessQueue.name);
|
||||
private queue: Queue | null = null;
|
||||
private connection: IORedis | null = null;
|
||||
|
||||
/** 首次使用时构造;连接错误经 no-op 监听吞掉(见文件头注释)。 */
|
||||
private getQueue(): Queue {
|
||||
if (this.queue === null) {
|
||||
const url = process.env.REDIS_URL_CN ?? 'redis://localhost:6379';
|
||||
const connection = new IORedis(url, {
|
||||
this.connection = new IORedis(url, {
|
||||
// BullMQ 对阻塞命令的要求:禁用 per-request 重试上限(等待 Worker 语义)。
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
connection.on('error', (err: Error) => {
|
||||
this.connection.on('error', (err: Error) => {
|
||||
this.logger.warn(
|
||||
`photo-process 队列连接异常(将由 ioredis 自动重连): ${err.message}`,
|
||||
);
|
||||
});
|
||||
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection });
|
||||
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection: this.connection });
|
||||
}
|
||||
return this.queue;
|
||||
}
|
||||
@@ -56,7 +57,8 @@ export class BullMqPhotoProcessQueue
|
||||
{ photoId },
|
||||
{
|
||||
// jobId 以 photoId 派生:并发 complete/补偿扫描重复入队天然去重(A03)。
|
||||
jobId: `${PHOTO_PROCESS_JOB_NAME}:${photoId}`,
|
||||
// 注意 BullMQ 禁止 jobId 含 ':'(保留分隔符),用 '-' 连接。
|
||||
jobId: `${PHOTO_PROCESS_JOB_NAME}-${photoId}`,
|
||||
// Task 21 消费侧重试策略:3 次指数退避。
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
@@ -77,6 +79,12 @@ export class BullMqPhotoProcessQueue
|
||||
await this.queue.close().catch(() => undefined);
|
||||
this.queue = null;
|
||||
}
|
||||
// BullMQ 不接管外部传入连接的生命周期:queue.close 不 disconnect 它,
|
||||
// 这里显式断开,否则进程(含 e2e 的 app 实例)残留打开的 Redis 句柄。
|
||||
if (this.connection !== null) {
|
||||
await this.connection.quit().catch(() => undefined);
|
||||
this.connection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起)。
|
||||
// 方法名与契约 operationId 一一对应(initUpload;Task 19 增补 renewUploadCredential/
|
||||
// completeUpload/listUploads)。控制器级 SessionGuard:端点全需认证。职责:
|
||||
// 透传认证用户 + 查询参数规整(parseLimit/parseCursor 复用相册控制器导出)。
|
||||
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起;
|
||||
// Task 19 增补 listUploads/renewUploadCredential/completeUpload)。方法名与契约
|
||||
// operationId 一一对应。控制器级 SessionGuard:端点全需认证。职责:透传认证
|
||||
// 用户 + 查询参数规整(parseLimit/parseCursor 复用相册控制器导出,同契约 bounds)。
|
||||
// 注意 DTO 必须值导入(非 import type):@Body 参数类型经 emitDecoratorMetadata
|
||||
// 进入设计期元数据,import type 会令参数在管线中被置为 undefined(Task 18 实测)。
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { userOf } from '../albums/albums.controller';
|
||||
import {
|
||||
parseCursor,
|
||||
parseLimit,
|
||||
userOf,
|
||||
} from '../albums/albums.controller';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { UploadsService } from './uploads.service';
|
||||
import type { UploadInitResponse } from './uploads.service';
|
||||
import type {
|
||||
UploadCompleteResponse,
|
||||
UploadInitResponse,
|
||||
UploadSessionListResponse,
|
||||
} from './uploads.service';
|
||||
import { UploadInitRequestDto } from './dto';
|
||||
|
||||
@Controller('uploads')
|
||||
@@ -28,4 +43,38 @@ export class UploadsController {
|
||||
): Promise<UploadInitResponse> {
|
||||
return this.uploadsService.init(userOf(req), dto);
|
||||
}
|
||||
|
||||
/** 上传历史:当前用户自己的会话,新在前,(createdAt,id) 键集游标。 */
|
||||
@Get()
|
||||
listUploads(
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<UploadSessionListResponse> {
|
||||
return this.uploadsService.list(
|
||||
userOf(req),
|
||||
parseCursor(cursor),
|
||||
parseLimit(limit),
|
||||
);
|
||||
}
|
||||
|
||||
/** 续签:仅 pending 会话;返回结构同 initUpload(credential 为新值)。 */
|
||||
@Post(':uploadId/renew')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
renewUploadCredential(
|
||||
@Param('uploadId') uploadId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<UploadInitResponse> {
|
||||
return this.uploadsService.renew(userOf(req), uploadId);
|
||||
}
|
||||
|
||||
/** 完成核验:幂等(重复调用返回相同结果,不重复建 job)。 */
|
||||
@Post(':uploadId/complete')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
completeUpload(
|
||||
@Param('uploadId') uploadId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<UploadCompleteResponse> {
|
||||
return this.uploadsService.complete(userOf(req), uploadId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// services/api/src/uploads/uploads.service.ts —— 上传域核心逻辑(Task 18 起)。
|
||||
// 契约权威:packages/contracts/openapi.yaml tags [uploads]。要点:
|
||||
// services/api/src/uploads/uploads.service.ts —— 上传域核心逻辑(Task 18 起,
|
||||
// Task 19 增补 renew/complete/list)。契约权威:packages/contracts/openapi.yaml
|
||||
// tags [uploads]。要点:
|
||||
// · initUpload 幂等初始化(A03):@@unique([userId, clientTaskId]) 为幂等锚,
|
||||
// 同键同参返回原会话(201,不重复建照片);同键异参 409 UPLOAD_SESSION_CONFLICT。
|
||||
// 并发同键插入由 P2002 捕获后回读既有会话裁决(MySQL 约束冲突不中止事务)。
|
||||
@@ -13,10 +14,26 @@
|
||||
// · init 不落 audit(高频低风险;审计锚点在 complete,A06 前置约定)。
|
||||
// · UPLOAD_MAX_BYTES/SIGNED_URL_TTL_SECONDS 逐请求惰性读取(support),
|
||||
// presign 在事务外执行(SigV4 为本地计算,无网络副作用)。
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
// · renew(Task 19):仅 pending 会话可续签;凭证过期→renew 是契约规定的恢复
|
||||
// 路径,故 renew 同时滑动顺延会话 expiresAt;相册关闭上传即拒签(§4.9)。
|
||||
// · complete(Task 19):核验失败(head staging:缺失/字节数不符/contentType
|
||||
// 与申明不符——presign 不签名 content-type,Task 11 发现的服务端兜底)是
|
||||
// 请求级 400,会话保持 pending 可重试;通过后 copyIfAbsent(禁止覆盖语义)
|
||||
// 受控复制到正式键,再进事务:photo(bytes/uploadedAt/uploaded) + 会话抢占
|
||||
// 置 confirmed(updateMany 条件更新,并发 complete 恰一人胜出)+ 胜者独占
|
||||
// photo_jobs upsert 与 audit;提交后入队 photo-process(失败仅日志,Task 21
|
||||
// sweep 以 photo_jobs(pending) 为事实源补偿)。confirmed 会话重复 complete
|
||||
// 走幂等路径:copyIfAbsent 复核(originals 已在则 created:false,staging 已
|
||||
// 被清理也不受影响)后直接回显当前 processingState——不重复 job/audit/入队。
|
||||
// · 权限逐次校验(A02):renew/complete 每次调用都重查成员权——被移除成员
|
||||
// 的下一次请求即刻 403(会话/照片 ID 均已持有也不放行)。
|
||||
import { Inject, Injectable, HttpStatus } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
import { StorageError, type StoragePort } from '@photo/storage';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { PermissionsService } from '../albums/permissions.service';
|
||||
import {
|
||||
albumForbidden,
|
||||
@@ -25,7 +42,16 @@ import {
|
||||
validationFailed,
|
||||
} from '../albums/support';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import { STORAGE_PORT } from './storage.provider';
|
||||
import {
|
||||
PHOTO_PROCESS_QUEUE,
|
||||
type PhotoProcessQueueProducer,
|
||||
} from './queue.provider';
|
||||
import type { UploadInitRequestDto } from './dto';
|
||||
import {
|
||||
albumUploadDisabled,
|
||||
@@ -37,10 +63,17 @@ import {
|
||||
stagingObjectKey,
|
||||
uploadMaxBytes,
|
||||
uploadSessionConflict,
|
||||
uploadSessionForbidden,
|
||||
uploadVerifyFailed,
|
||||
} from './support';
|
||||
|
||||
export type UploadInitResponse =
|
||||
components['schemas']['UploadInitResponse'];
|
||||
export type UploadCompleteResponse =
|
||||
components['schemas']['UploadCompleteResponse'];
|
||||
export type UploadSessionListResponse =
|
||||
components['schemas']['UploadSessionList'];
|
||||
type UploadSessionRecord = components['schemas']['UploadSessionRecord'];
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
@@ -51,12 +84,16 @@ type SessionRow = {
|
||||
albumId: string;
|
||||
};
|
||||
|
||||
type UploadSessionRow = Prisma.upload_sessionsGetPayload<Prisma.upload_sessionsDefaultArgs>;
|
||||
|
||||
@Injectable()
|
||||
export class UploadsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||
@Inject(PHOTO_PROCESS_QUEUE)
|
||||
private readonly queue: PhotoProcessQueueProducer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -202,4 +239,277 @@ export class UploadsService {
|
||||
extOfObjectKey(session.objectKey) === ext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话定位 + 归属 + 逐次权限核验(renew/complete 共用)。任何一项不过都抛
|
||||
* 同一 403 AUTH_FORBIDDEN(契约:无权/不存在不区分,防 uploadId 枚举)。
|
||||
* 返回值保证非空且 userId === user.id。
|
||||
*/
|
||||
private async locateAuthorizedSession(
|
||||
user: AuthUser,
|
||||
uploadId: string,
|
||||
): Promise<UploadSessionRow> {
|
||||
const session = await this.prisma.upload_sessions.findUnique({
|
||||
where: { id: uploadId },
|
||||
});
|
||||
if (session === null || session.userId !== user.id) {
|
||||
throw uploadSessionForbidden();
|
||||
}
|
||||
// A02 逐次校验:被移除成员/册消失后的下一次 renew/complete 即刻拦截。
|
||||
if (!(await this.permissions.canUploadToAlbum(user, session.albumId))) {
|
||||
throw uploadSessionForbidden();
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 续签(Task 19):仅 pending 会话;同 staging 键现签新凭证,并滑动顺延会话
|
||||
* expiresAt(契约 UploadCredential.expiresAt:过期后续签再重试——renew 是
|
||||
* 凭证过期的恢复路径,不得因过期而拒绝)。相册关闭上传即拒(§4.9)。
|
||||
*/
|
||||
async renew(
|
||||
user: AuthUser,
|
||||
uploadId: string,
|
||||
): Promise<UploadInitResponse> {
|
||||
const session = await this.locateAuthorizedSession(user, uploadId);
|
||||
if (session.state !== 'pending') {
|
||||
throw uploadSessionConflict('会话状态不允许续签(已完成/取消/过期)');
|
||||
}
|
||||
const album = await this.prisma.albums.findUnique({
|
||||
where: { id: session.albumId },
|
||||
select: { uploadEnabled: true },
|
||||
});
|
||||
if (album === null) {
|
||||
// 相册行已不存在:同 403 防枚举(与不可见同码)。
|
||||
throw uploadSessionForbidden();
|
||||
}
|
||||
if (!album.uploadEnabled) {
|
||||
throw albumUploadDisabled();
|
||||
}
|
||||
const ttl = signedUrlTtlSeconds();
|
||||
const credential = await this.storage.presignPut(
|
||||
session.objectKey,
|
||||
session.expectedBytes,
|
||||
session.contentType,
|
||||
ttl,
|
||||
);
|
||||
await this.prisma.upload_sessions.update({
|
||||
where: { id: session.id },
|
||||
data: { expiresAt: new Date(Date.now() + ttl * 1000) },
|
||||
});
|
||||
return {
|
||||
uploadId: session.id,
|
||||
photoId: session.photoId,
|
||||
objectKey: session.objectKey,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成核验(Task 19):head 核验 → 受控复制 → 事务三表 + audit → 提交后入队。
|
||||
* 幂等(A03):confirmed 会话重复调用经 copyIfAbsent 复核(originals 已在则
|
||||
* created:false;staging 已清理亦不受影响)后回显当前状态,不产生第二个
|
||||
* job/audit/入队。核验失败是请求级 400,会话保持 pending 可重试。
|
||||
*/
|
||||
async complete(
|
||||
user: AuthUser,
|
||||
uploadId: string,
|
||||
): Promise<UploadCompleteResponse> {
|
||||
const session = await this.locateAuthorizedSession(user, uploadId);
|
||||
// 注意:契约 complete 无 ALBUM_UPLOAD_DISABLED——已完成的上传不因运营开关
|
||||
// 被阻断(开关拒绝的是新直传与续签)。
|
||||
if (session.state === 'cancelled' || session.state === 'expired') {
|
||||
throw uploadSessionConflict('会话状态不允许完成(已取消或已过期)');
|
||||
}
|
||||
if (
|
||||
session.state === 'pending' &&
|
||||
session.expiresAt.getTime() <= Date.now()
|
||||
) {
|
||||
throw uploadSessionConflict('会话已过期(已取消或已过期)');
|
||||
}
|
||||
const photo = await this.prisma.photos.findUnique({
|
||||
where: { id: session.photoId },
|
||||
});
|
||||
if (photo === null) {
|
||||
// 会话与照片同事务创建,缺行只可能是装配异常/外部破坏,按 5xx 兜底。
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
if (session.state === 'confirmed') {
|
||||
// 幂等快路径:originals 已核验过一次;copyIfAbsent 复核(created:false
|
||||
// 路径,不依赖 staging 源仍存在),不重复 job/audit/入队。
|
||||
await this.copyWithVerify(
|
||||
session.objectKey,
|
||||
photo.objectKey,
|
||||
session.expectedBytes,
|
||||
);
|
||||
const current = await this.prisma.photos.findUnique({
|
||||
where: { id: photo.id },
|
||||
select: { processingState: true },
|
||||
});
|
||||
const processingState = (current ?? photo)
|
||||
.processingState as UploadCompleteResponse['processingState'];
|
||||
return { photoId: photo.id, processingState };
|
||||
}
|
||||
|
||||
// ① 事务外存储核验:存在性/字节数/contentType(presign 不签名 content-type,
|
||||
// 服务端兜底——Task 11 审查发现)。
|
||||
const head = await this.storage.head(session.objectKey);
|
||||
if (head === null) {
|
||||
throw uploadVerifyFailed('staging 对象不存在或尚未上传');
|
||||
}
|
||||
if (head.bytes !== session.expectedBytes) {
|
||||
throw uploadVerifyFailed(
|
||||
`字节数与申明不符(expected=${session.expectedBytes}, actual=${head.bytes})`,
|
||||
);
|
||||
}
|
||||
if (head.contentType !== session.contentType) {
|
||||
throw uploadVerifyFailed(
|
||||
`Content-Type 与申明不符(expected=${session.contentType}, actual=${head.contentType})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ② 受控复制 staging → 正式键(禁止覆盖语义:目标已在且一致则幂等返回)。
|
||||
await this.copyWithVerify(
|
||||
session.objectKey,
|
||||
photo.objectKey,
|
||||
session.expectedBytes,
|
||||
);
|
||||
|
||||
// ③ 事务:photo 更新 + 会话条件更新抢占(并发 complete 恰一人胜出)+
|
||||
// 胜者独占 photo_jobs 与 audit。
|
||||
let claimed = false;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.photos.update({
|
||||
where: { id: photo.id },
|
||||
data: {
|
||||
bytes: session.expectedBytes,
|
||||
uploadedAt: new Date(),
|
||||
processingState: 'uploaded',
|
||||
},
|
||||
});
|
||||
const race = await tx.upload_sessions.updateMany({
|
||||
where: { id: session.id, state: 'pending' },
|
||||
data: { state: 'confirmed' },
|
||||
});
|
||||
if (race.count === 1) {
|
||||
claimed = true;
|
||||
await tx.photo_jobs.upsert({
|
||||
where: { photoId_type: { photoId: photo.id, type: 'process' } },
|
||||
update: {}, // 已有 job(外部补偿先行/重复投递)不重复创建。
|
||||
create: { photoId: photo.id, type: 'process', state: 'pending' },
|
||||
});
|
||||
await tx.audit_logs.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
action: 'upload.complete',
|
||||
resourceId: photo.id,
|
||||
metadata: {
|
||||
uploadId: session.id,
|
||||
bytes: session.expectedBytes,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ④ 提交后入队(失败仅日志:photo_jobs(pending) 是持久事实源,Task 21
|
||||
// sweep 补偿;jobId 以 photoId 派生,重复入队天然去重)。
|
||||
if (claimed) {
|
||||
await this.queue.enqueuePhotoProcess(photo.id);
|
||||
}
|
||||
return { photoId: photo.id, processingState: 'uploaded' };
|
||||
}
|
||||
|
||||
/** copyIfAbsent 包装:StorageError(UPLOAD_VERIFY_FAILED) → 同码 400 业务异常。 */
|
||||
private async copyWithVerify(
|
||||
src: string,
|
||||
dest: string,
|
||||
expectedBytes: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.storage.copyIfAbsent(src, dest, expectedBytes);
|
||||
} catch (err) {
|
||||
if (err instanceof StorageError) {
|
||||
throw uploadVerifyFailed(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传历史(Task 19):当前用户自己的会话(不分相册/状态),新在前
|
||||
* (createdAt,id) 键集游标;photo 子对象内嵌服务端处理进度与可见性。
|
||||
*/
|
||||
async list(
|
||||
user: AuthUser,
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<UploadSessionListResponse> {
|
||||
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||
if (cursor !== undefined && cursor !== '') {
|
||||
decoded = decodeKeysetCursor(cursor);
|
||||
if (decoded === null) {
|
||||
throw validationFailed('cursor 不合法');
|
||||
}
|
||||
}
|
||||
|
||||
const where: Prisma.upload_sessionsWhereInput = { userId: user.id };
|
||||
if (decoded !== null) {
|
||||
Object.assign(where, keysetWhereDesc('createdAt', 'id', decoded));
|
||||
}
|
||||
const rows = await this.prisma.upload_sessions.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1, // 多取一条探测 hasMore(同相册列表)
|
||||
});
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
// 相册域式扁平外键:照片二次查询后应用层组装(会话与照片 1:1 同事务创建)。
|
||||
const photos =
|
||||
page.length === 0
|
||||
? []
|
||||
: await this.prisma.photos.findMany({
|
||||
where: { id: { in: page.map((row) => row.photoId) } },
|
||||
select: { id: true, processingState: true, visibility: true },
|
||||
});
|
||||
const photoById = new Map(photos.map((photo) => [photo.id, photo]));
|
||||
|
||||
const last = page.at(-1);
|
||||
const nextCursor =
|
||||
hasMore && last !== undefined
|
||||
? encodeKeysetCursor({
|
||||
sort: last.createdAt.toISOString(),
|
||||
id: last.id,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((row) => {
|
||||
const photo = photoById.get(row.photoId);
|
||||
const record: UploadSessionRecord = {
|
||||
uploadId: row.id,
|
||||
albumId: row.albumId,
|
||||
clientTaskId: row.clientTaskId,
|
||||
state: row.state as UploadSessionRecord['state'],
|
||||
expectedBytes: row.expectedBytes,
|
||||
contentType: row.contentType,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
photo: {
|
||||
photoId: row.photoId,
|
||||
processingState:
|
||||
(photo?.processingState ?? 'pending_upload') as UploadSessionRecord['photo']['processingState'],
|
||||
visibility: (photo?.visibility ?? 'unpublished') as UploadSessionRecord['photo']['visibility'],
|
||||
},
|
||||
};
|
||||
return record;
|
||||
}),
|
||||
nextCursor,
|
||||
hasMore: nextCursor !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user