582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import {
|
|
mobileMediaAssetInputSchema,
|
|
pingMobileDeviceSchema,
|
|
registerMobileDeviceSchema,
|
|
syncMobileSMSSchema,
|
|
} from "@pkg/logic/domains/mobile/data";
|
|
import { getFileController } from "@pkg/logic/domains/files/controller";
|
|
import { getMobileController } from "@pkg/logic/domains/mobile/controller";
|
|
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
|
|
import { errorStatusMap, type Err } from "@pkg/result";
|
|
import { logDomainEvent } from "@pkg/logger";
|
|
import type { Context } from "hono";
|
|
import * as v from "valibot";
|
|
import { Hono } from "hono";
|
|
import { createHash } from "node:crypto";
|
|
|
|
const mobileController = getMobileController();
|
|
const fileController = getFileController();
|
|
const DEVICE_ID_HEADER = "x-device-id";
|
|
const FLOW_ID_HEADER = "x-flow-id";
|
|
const MEDIA_EXTERNAL_ID_HEADER = "x-media-external-id";
|
|
const MEDIA_MIME_TYPE_HEADER = "x-media-mime-type";
|
|
const MEDIA_FILENAME_HEADER = "x-media-filename";
|
|
const MEDIA_CAPTURED_AT_HEADER = "x-media-captured-at";
|
|
const MEDIA_SIZE_BYTES_HEADER = "x-media-size-bytes";
|
|
const MEDIA_HASH_HEADER = "x-media-hash";
|
|
const MEDIA_METADATA_HEADER = "x-media-metadata";
|
|
|
|
function buildFlowExecCtx(c: Context): FlowExecCtx {
|
|
return {
|
|
flowId: c.req.header(FLOW_ID_HEADER) || crypto.randomUUID(),
|
|
};
|
|
}
|
|
|
|
function respondError(c: Context, fctx: FlowExecCtx, err: Err) {
|
|
const status = errorStatusMap[err.code] || 500;
|
|
c.status(status as any);
|
|
return c.json({ data: null, error: { ...err, flowId: fctx.flowId } });
|
|
}
|
|
|
|
function requireExternalDeviceId(c: Context): string | null {
|
|
const externalDeviceId = c.req.header(DEVICE_ID_HEADER);
|
|
if (!externalDeviceId || externalDeviceId.trim().length === 0) {
|
|
return null;
|
|
}
|
|
return externalDeviceId.trim();
|
|
}
|
|
|
|
async function parseJson(c: Context) {
|
|
try {
|
|
return await c.req.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function toOptionalString(
|
|
value: FormDataEntryValue | string | null | undefined,
|
|
): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function parseOptionalJsonRecord(value: string | undefined): {
|
|
value?: Record<string, unknown>;
|
|
error?: string;
|
|
} {
|
|
if (!value) {
|
|
return {};
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return { value: parsed as Record<string, unknown> };
|
|
}
|
|
return { error: "metadata must be a JSON object" };
|
|
} catch {
|
|
return { error: "metadata must be valid JSON" };
|
|
}
|
|
}
|
|
|
|
async function makeFileHash(file: File): Promise<string | null> {
|
|
try {
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
return createHash("sha256").update(buffer).digest("hex");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const mobileRouter = new Hono()
|
|
.post("/register", async (c) => {
|
|
const fctx = buildFlowExecCtx(c);
|
|
const startedAt = Date.now();
|
|
|
|
logDomainEvent({
|
|
event: "processor.mobile.register.started",
|
|
fctx,
|
|
});
|
|
|
|
const payload = await parseJson(c);
|
|
const parsed = v.safeParse(registerMobileDeviceSchema, payload);
|
|
if (!parsed.success) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Invalid register payload",
|
|
description: "Please validate the payload and retry",
|
|
detail: parsed.issues.map((issue) => issue.message).join(", "),
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const result = await mobileController.registerDevice(
|
|
fctx,
|
|
parsed.output,
|
|
);
|
|
if (result.isErr()) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "processor.mobile.register.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
});
|
|
return respondError(c, fctx, result.error);
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "processor.mobile.register.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { deviceId: result.value.id },
|
|
});
|
|
return c.json({ data: result.value, error: null });
|
|
})
|
|
.put("/ping", async (c) => {
|
|
const fctx = buildFlowExecCtx(c);
|
|
const startedAt = Date.now();
|
|
const externalDeviceId = requireExternalDeviceId(c);
|
|
|
|
if (!externalDeviceId) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "AUTH_ERROR",
|
|
message: "Missing device id header",
|
|
description: `Request must include ${DEVICE_ID_HEADER}`,
|
|
detail: `${DEVICE_ID_HEADER} header is required`,
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const payload = await parseJson(c);
|
|
const parsed = v.safeParse(pingMobileDeviceSchema, payload ?? {});
|
|
if (!parsed.success) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Invalid ping payload",
|
|
description: "Please validate the payload and retry",
|
|
detail: parsed.issues.map((issue) => issue.message).join(", "),
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const result = await mobileController.pingByExternalDeviceId(
|
|
fctx,
|
|
externalDeviceId,
|
|
parsed.output,
|
|
);
|
|
if (result.isErr()) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "processor.mobile.ping.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: { externalDeviceId },
|
|
});
|
|
return respondError(c, fctx, result.error);
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "processor.mobile.ping.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { externalDeviceId },
|
|
});
|
|
return c.json({ data: { ok: result.value }, error: null });
|
|
})
|
|
.put("/sms/sync", async (c) => {
|
|
const fctx = buildFlowExecCtx(c);
|
|
const startedAt = Date.now();
|
|
const externalDeviceId = requireExternalDeviceId(c);
|
|
|
|
if (!externalDeviceId) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "AUTH_ERROR",
|
|
message: "Missing device id header",
|
|
description: `Request must include ${DEVICE_ID_HEADER}`,
|
|
detail: `${DEVICE_ID_HEADER} header is required`,
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const payload = await parseJson(c);
|
|
const parsed = v.safeParse(syncMobileSMSSchema, payload);
|
|
if (!parsed.success) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Invalid SMS sync payload",
|
|
description: "Please validate the payload and retry",
|
|
detail: parsed.issues.map((issue) => issue.message).join(", "),
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const result = await mobileController.syncSMSByExternalDeviceId(
|
|
fctx,
|
|
externalDeviceId,
|
|
parsed.output.messages,
|
|
);
|
|
if (result.isErr()) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "processor.mobile.sms_sync.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: {
|
|
externalDeviceId,
|
|
received: parsed.output.messages.length,
|
|
},
|
|
});
|
|
return respondError(c, fctx, result.error);
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "processor.mobile.sms_sync.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: {
|
|
externalDeviceId,
|
|
...result.value,
|
|
},
|
|
});
|
|
return c.json({ data: result.value, error: null });
|
|
})
|
|
.put("/media/sync", async (c) => {
|
|
const fctx = buildFlowExecCtx(c);
|
|
const startedAt = Date.now();
|
|
const externalDeviceId = requireExternalDeviceId(c);
|
|
|
|
if (!externalDeviceId) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "AUTH_ERROR",
|
|
message: "Missing device id header",
|
|
description: `Request must include ${DEVICE_ID_HEADER}`,
|
|
detail: `${DEVICE_ID_HEADER} header is required`,
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const form = await c.req.formData().catch(() => null);
|
|
if (!form) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Invalid media upload request",
|
|
description: "Expected multipart/form-data with a file field",
|
|
detail: "Unable to parse multipart form data",
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const fileEntry = form.get("file");
|
|
if (!(fileEntry instanceof File)) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Missing media file",
|
|
description: "Expected a file part named 'file'",
|
|
detail: "multipart/form-data requires file field",
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const sizeBytesRaw =
|
|
toOptionalString(form.get("sizeBytes")) ||
|
|
toOptionalString(c.req.header(MEDIA_SIZE_BYTES_HEADER));
|
|
const sizeBytesParsed =
|
|
sizeBytesRaw !== undefined ? Number(sizeBytesRaw) : undefined;
|
|
|
|
const metadataRaw =
|
|
toOptionalString(form.get("metadata")) ||
|
|
toOptionalString(c.req.header(MEDIA_METADATA_HEADER));
|
|
const metadataResult = parseOptionalJsonRecord(metadataRaw);
|
|
if (metadataResult.error) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Invalid media metadata",
|
|
description: "Please provide metadata as a JSON object",
|
|
detail: metadataResult.error,
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const mediaPayload = {
|
|
externalMediaId:
|
|
toOptionalString(form.get("externalMediaId")) ||
|
|
toOptionalString(c.req.header(MEDIA_EXTERNAL_ID_HEADER)),
|
|
fileId: crypto.randomUUID(),
|
|
mimeType:
|
|
toOptionalString(form.get("mimeType")) ||
|
|
toOptionalString(c.req.header(MEDIA_MIME_TYPE_HEADER)) ||
|
|
fileEntry.type ||
|
|
"application/octet-stream",
|
|
filename:
|
|
toOptionalString(form.get("filename")) ||
|
|
toOptionalString(c.req.header(MEDIA_FILENAME_HEADER)) ||
|
|
fileEntry.name,
|
|
capturedAt:
|
|
toOptionalString(form.get("capturedAt")) ||
|
|
toOptionalString(c.req.header(MEDIA_CAPTURED_AT_HEADER)),
|
|
sizeBytes: Number.isFinite(sizeBytesParsed)
|
|
? sizeBytesParsed
|
|
: fileEntry.size,
|
|
hash:
|
|
toOptionalString(form.get("hash")) ||
|
|
toOptionalString(c.req.header(MEDIA_HASH_HEADER)),
|
|
metadata: metadataResult.value,
|
|
};
|
|
|
|
const parsed = v.safeParse(mobileMediaAssetInputSchema, mediaPayload);
|
|
if (!parsed.success) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "VALIDATION_ERROR",
|
|
message: "Invalid media upload metadata",
|
|
description: "Please validate multipart metadata and retry",
|
|
detail: parsed.issues.map((issue) => issue.message).join(", "),
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const deviceResult = await mobileController.resolveDeviceByExternalId(
|
|
fctx,
|
|
externalDeviceId,
|
|
);
|
|
if (deviceResult.isErr()) {
|
|
return respondError(c, fctx, deviceResult.error);
|
|
}
|
|
|
|
const externalMediaId = parsed.output.externalMediaId ?? null;
|
|
const mediaHash = parsed.output.hash ?? (await makeFileHash(fileEntry));
|
|
if (externalMediaId) {
|
|
const existingResult =
|
|
await mobileController.findMediaAssetByExternalMediaId(
|
|
fctx,
|
|
deviceResult.value.id,
|
|
externalMediaId,
|
|
);
|
|
if (existingResult.isErr()) {
|
|
return respondError(c, fctx, existingResult.error);
|
|
}
|
|
|
|
if (existingResult.value) {
|
|
logDomainEvent({
|
|
event: "processor.mobile.media_sync.duplicate_skipped",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: {
|
|
externalDeviceId,
|
|
externalMediaId,
|
|
deviceId: deviceResult.value.id,
|
|
mediaAssetId: existingResult.value.id,
|
|
fileId: existingResult.value.fileId,
|
|
},
|
|
});
|
|
return c.json({
|
|
data: {
|
|
received: 1,
|
|
inserted: 0,
|
|
deviceId: deviceResult.value.id,
|
|
fileId: existingResult.value.fileId,
|
|
deduplicated: true,
|
|
},
|
|
error: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!externalMediaId && mediaHash) {
|
|
const existingByHash = await mobileController.findMediaAssetByHash(
|
|
fctx,
|
|
deviceResult.value.id,
|
|
mediaHash,
|
|
);
|
|
if (existingByHash.isErr()) {
|
|
return respondError(c, fctx, existingByHash.error);
|
|
}
|
|
|
|
if (existingByHash.value) {
|
|
logDomainEvent({
|
|
event: "processor.mobile.media_sync.duplicate_skipped",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: {
|
|
externalDeviceId,
|
|
dedupKey: "hash",
|
|
mediaHash,
|
|
deviceId: deviceResult.value.id,
|
|
mediaAssetId: existingByHash.value.id,
|
|
fileId: existingByHash.value.fileId,
|
|
},
|
|
});
|
|
return c.json({
|
|
data: {
|
|
received: 1,
|
|
inserted: 0,
|
|
deviceId: deviceResult.value.id,
|
|
fileId: existingByHash.value.fileId,
|
|
deduplicated: true,
|
|
},
|
|
error: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const uploadResult = await fileController.uploadFile(
|
|
fctx,
|
|
deviceResult.value.ownerUserId,
|
|
fileEntry,
|
|
{
|
|
visibility: "private",
|
|
metadata: {
|
|
source: "mobile.media.sync",
|
|
externalDeviceId,
|
|
externalMediaId: parsed.output.externalMediaId || null,
|
|
...(parsed.output.metadata || {}),
|
|
},
|
|
tags: ["mobile", "media-sync"],
|
|
processImage: parsed.output.mimeType.startsWith("image/"),
|
|
processVideo: parsed.output.mimeType.startsWith("video/"),
|
|
},
|
|
);
|
|
if (uploadResult.isErr()) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "processor.mobile.media_upload.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: uploadResult.error,
|
|
meta: { externalDeviceId, filename: fileEntry.name },
|
|
});
|
|
return respondError(c, fctx, uploadResult.error);
|
|
}
|
|
|
|
const uploadedFileId =
|
|
uploadResult.value.file?.id || uploadResult.value.uploadId || null;
|
|
if (!uploadedFileId) {
|
|
const error = {
|
|
flowId: fctx.flowId,
|
|
code: "INTERNAL_ERROR",
|
|
message: "Failed to resolve uploaded file id",
|
|
description: "Please retry media upload",
|
|
detail: "File upload succeeded but returned no file id",
|
|
} as Err;
|
|
return respondError(c, fctx, error);
|
|
}
|
|
|
|
const result = await mobileController.syncMediaByExternalDeviceId(
|
|
fctx,
|
|
externalDeviceId,
|
|
[{ ...parsed.output, hash: mediaHash ?? undefined, fileId: uploadedFileId }],
|
|
);
|
|
if (result.isErr()) {
|
|
await fileController.deleteFiles(
|
|
fctx,
|
|
[uploadedFileId],
|
|
deviceResult.value.ownerUserId,
|
|
);
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "processor.mobile.media_sync.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: {
|
|
externalDeviceId,
|
|
received: 1,
|
|
},
|
|
});
|
|
return respondError(c, fctx, result.error);
|
|
}
|
|
|
|
if (result.value.inserted === 0) {
|
|
const rollback = await fileController.deleteFiles(
|
|
fctx,
|
|
[uploadedFileId],
|
|
deviceResult.value.ownerUserId,
|
|
);
|
|
if (rollback.isErr()) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "processor.mobile.media_sync.duplicate_cleanup_failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: rollback.error,
|
|
meta: {
|
|
externalDeviceId,
|
|
fileId: uploadedFileId,
|
|
externalMediaId,
|
|
},
|
|
});
|
|
return respondError(c, fctx, rollback.error);
|
|
}
|
|
|
|
let dedupedFileId = uploadedFileId;
|
|
if (externalMediaId) {
|
|
const existingAfterInsert =
|
|
await mobileController.findMediaAssetByExternalMediaId(
|
|
fctx,
|
|
deviceResult.value.id,
|
|
externalMediaId,
|
|
);
|
|
if (existingAfterInsert.isOk() && existingAfterInsert.value) {
|
|
dedupedFileId = existingAfterInsert.value.fileId;
|
|
}
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "processor.mobile.media_sync.duplicate_cleaned",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: {
|
|
externalDeviceId,
|
|
externalMediaId,
|
|
deviceId: deviceResult.value.id,
|
|
uploadedFileId,
|
|
dedupedFileId,
|
|
},
|
|
});
|
|
|
|
return c.json({
|
|
data: {
|
|
...result.value,
|
|
fileId: dedupedFileId,
|
|
deduplicated: true,
|
|
},
|
|
error: null,
|
|
});
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "processor.mobile.media_sync.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: {
|
|
externalDeviceId,
|
|
fileId: uploadedFileId,
|
|
...result.value,
|
|
},
|
|
});
|
|
|
|
return c.json({
|
|
data: {
|
|
...result.value,
|
|
fileId: uploadedFileId,
|
|
deduplicated: false,
|
|
},
|
|
error: null,
|
|
});
|
|
});
|