Compare commits

...

23 Commits

Author SHA1 Message Date
user
1310ff3220 now files do presigned url setup 2026-03-02 21:40:02 +02:00
user
34aa52ec7c well yuh 2026-03-02 21:26:47 +02:00
user
939d4ca8a8 welll let's see now if the main is fixed 2026-03-02 20:26:42 +02:00
user
2c7ab39693 removed useless server ts file 2026-03-02 20:08:43 +02:00
user
fa6374f6cf idk 2026-03-02 19:51:36 +02:00
user
a4280404dc better docker file 2026-03-02 19:49:46 +02:00
user
5c84f4a672 semi-thicker, but eh works now 2026-03-02 19:46:58 +02:00
user
a7ed537b07 added host+port env in prod script for proc. app 2026-03-02 19:25:03 +02:00
user
be45cc3fa7 resolved type errors and other proc. app build error 2026-03-02 19:23:59 +02:00
user
6069a16d80 proc. package script fix 2026-03-02 19:09:48 +02:00
user
ed0a7d5c67 dockerfile fix 2026-03-02 18:42:53 +02:00
user
e6e27de780 dockerfile fix 2026-03-02 18:22:36 +02:00
user
29f5721684 updated readme file 2026-03-01 20:52:31 +02:00
user
82e9dbed1a updated mobile media uploader logic to be more redundant 2026-03-01 19:54:10 +02:00
user
3940130dad fixing sum' file logic flaws & device UI stuff stuff 2026-03-01 19:29:27 +02:00
user
48792692ff add: delete actions for device + slightly better ui 2026-03-01 18:59:43 +02:00
user
d000ccfeca sms scrolling now 2026-03-01 18:49:57 +02:00
user
5d66dbceb7 better media setup 2026-03-01 18:46:49 +02:00
user
cf736095a4 added created column in devices table 2026-03-01 18:25:02 +02:00
user
9716deead7 proper asset creation/deletion, also raw file view in admin as well 2026-03-01 18:22:49 +02:00
user
6c2b917088 fix in ui for a refresh 2026-03-01 09:26:26 +02:00
user
17039aa3dd added observability support to processor app 2026-03-01 08:34:46 +02:00
user
eb2f82247b base implementation done, now onto mobile app 2026-03-01 07:39:49 +02:00
40 changed files with 3008 additions and 439 deletions

View File

@@ -1,41 +1,34 @@
APP_NAME=${{project.APP_NAME}} APP_NAME=${{project.APP_NAME}}
NODE_ENV=${{project.NODE_ENV}} NODE_ENV=${{project.NODE_ENV}}
LOG_LEVEL=${{project.LOG_LEVEL}}
REDIS_URL=${{project.REDIS_URL}} REDIS_URL=${{project.REDIS_URL}}
DATABASE_URL=${{project.DATABASE_URL}} DATABASE_URL=${{project.DATABASE_URL}}
COUCHBASE_CONNECTION_STRING=${{project.COUCHBASE_CONNECTION_STRING}} INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
COUCHBASE_USERNAME=${{project.COUCHBASE_USERNAME}} DEBUG_KEY=${{project.DEBUG_KEY}}
COUCHBASE_PASSWORD=${{project.COUCHBASE_PASSWORD}}
QDRANT_URL=${{project.QDRANT_URL}} PUBLIC_URL=${{project.PUBLIC_URL}}
QDRANT_API_KEY=${{project.QDRANT_API_KEY}}
PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}}
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}} BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}} BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}}
GOOGLE_CLIENT_ID=${{project.GOOGLE_CLIENT_ID}} TWOFA_SECRET=${{project.TWOFA_SECRET}}
GOOGLE_CLIENT_SECRET=${{project.GOOGLE_CLIENT_SECRET}} TWO_FA_SESSION_EXPIRY_MINUTES=${{project.TWO_FA_SESSION_EXPIRY_MINUTES}}
GOOGLE_OAUTH_SERVER_URL=${{project.GOOGLE_OAUTH_SERVER_URL}} TWO_FA_REQUIRED_HOURS=${{project.TWO_FA_REQUIRED_HOURS}}
GOOGLE_OAUTH_SERVER_PORT=${{project.GOOGLE_OAUTH_SERVER_PORT}}
GEMINI_API_KEY=${{project.GEMINI_API_KEY}} DEFAULT_ADMIN_EMAIL=${{project.DEFAULT_ADMIN_EMAIL}}
DEFAULT_ADMIN_PASSWORD=${{project.DEFAULT_ADMIN_PASSWORD}}
RESEND_API_KEY=${{project.RESEND_API_KEY}} OTEL_SERVICE_NAME=${{project.OTEL_SERVICE_NAME}}
FROM_EMAIL=${{project.FROM_EMAIL}} OTEL_TRACES_EXPORTER=${{project.OTEL_TRACES_EXPORTER}}
OTEL_EXPORTER_OTLP_HTTP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_HTTP_ENDPOINT}}
INTERNAL_API_KEY="supersecretkey" OTEL_EXPORTER_OTLP_GRPC_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_GRPC_ENDPOINT}}
OTEL_EXPORTER_OTLP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}}
PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}} OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}}
OPENROUTER_API_KEY=${{project.OPENROUTER_API_KEY}}
MODEL_NAME=${{project.MODEL_NAME}}
OCR_SERVICE_URL=${{project.OCR_SERVICE_URL}}
OCR_SERVICE_TIMEOUT=${{project.OCR_SERVICE_TIMEOUT}}
OCR_SERVICE_MAX_RETRIES=${{project.OCR_SERVICE_MAX_RETRIES}}
OCR_FORCE_OCR=${{project.OCR_FORCE_OCR}}
OCR_DEBUG=${{project.OCR_DEBUG}}
R2_BUCKET_NAME=${{project.R2_BUCKET_NAME}} R2_BUCKET_NAME=${{project.R2_BUCKET_NAME}}
R2_REGION=${{project.R2_REGION}} R2_REGION=${{project.R2_REGION}}

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ node_modules
__pycache__ __pycache__
.venv .venv
secret.md
# ignore generated log files # ignore generated log files
**/logs/**.log **/logs/**.log
**/logs/**.log.gz **/logs/**.log.gz

196
README.md
View File

@@ -1,197 +1,3 @@
# Illusory MAPP # Illusory MAPP
Internal automation platform for aggregating device data into one backend, then running coordinated automations on top of that data. Illusory MAPP is an internal project focused on building the foundation for future automation workflows. Right now, the platform provides a working ingestion backend and a simple admin dashboard: devices can register and sync data, and the dashboard lets the team inspect each devices current state, including SMS history and uploaded gallery/media assets. This gives us a stable operational base for visibility and data quality before layering on higher-level automation features.
## Current Goal (Server-Side Stage 1)
Build the server-side ingestion and admin visibility for:
1. Device registration
2. Device heartbeat/ping tracking
3. SMS ingestion (ongoing)
4. Media metadata ingestion (initial sync)
5. Admin UI to inspect devices, SMS, and media
Mobile app implementation is out of scope for now.
## Scope and Non-Goals
### In scope now
- Processor endpoints that receive mobile data
- Domain logic + persistence for devices, SMS, media sync metadata
- Admin UI read views with polling for SMS (every 5 seconds)
- Observability for ingestion and read flows (logs + traces)
### Not in scope now
- Mobile app UX/screens or client implementation
- Automation execution workflows
- Advanced media processing pipelines
## Delivery Plan (Actionable Tasks)
Use this as the step-by-step implementation checklist.
### Phase 0: Align Critical Decisions (before coding)
- [x] Confirm device identity strategy (`deviceId` from mobile vs server-generated key).
- [x] Confirm admin ownership rule for newly registered devices (single admin user mapping).
- [x] Confirm SMS dedup strategy (client message id, hash, or `(deviceId + timestamp + sender + body)`).
- [x] Confirm media sync contract: metadata-only now vs metadata + upload references.
- [x] Confirm minimal auth for processor endpoints (shared token or signed header) for Stage 1.
Decisions captured:
- Keep two identifiers per device:
- `id` (server-generated primary key)
- `externalDeviceId` (sent by mobile)
- All devices are owned by the single admin user in Stage 1.
- SMS dedup uses:
- optional client message id when available
- fallback deterministic hash from `(deviceId + timestamp + sender + body)`.
- Media sync is metadata plus upload reference (`fileId`) to the file domain/R2 object.
- Stage 1 endpoint auth:
- register is open in trusted network
- ping/sync endpoints must include device id header; request is allowed only if device exists.
### Phase 1: Data Model and DB Migrations
Target: `packages/db/schema/*` + new migration files.
- [x] Add `mobile_device` table:
- Fields: `id`, `externalDeviceId`, `name`, `manufacturer`, `model`, `androidVersion`, `ownerUserId`, `lastPingAt`, `createdAt`, `updatedAt`.
- [x] Add `mobile_sms` table:
- Fields: `id`, `deviceId`, `externalMessageId?`, `sender`, `recipient?`, `body`, `sentAt`, `receivedAt?`, `rawPayload?`, `createdAt`.
- [x] Add `mobile_media_asset` table:
- Fields: `id`, `deviceId`, `externalMediaId?`, `mimeType`, `filename?`, `capturedAt?`, `sizeBytes?`, `hash?`, `metadata`, `createdAt`.
- [x] Add indexes for common reads:
- `mobile_device.lastPingAt`
- `mobile_sms.deviceId + sentAt desc`
- `mobile_media_asset.deviceId + createdAt desc`
- [x] Export schema from `packages/db/schema/index.ts`.
Definition of done:
- [x] Tables and indexes exist in schema + migration files.
- [x] Naming matches existing conventions.
### Phase 2: Logic Domain (`@pkg/logic`)
Target: `packages/logic/domains/mobile/*` (new domain).
- [x] Create `data.ts` with Valibot schemas and inferred types for:
- register device payload
- ping payload
- sms sync payload
- media sync payload
- admin query filters/pagination
- [x] Create `errors.ts` with domain error constructors using `getError`.
- [x] Create `repository.ts` with ResultAsync operations for:
- upsert device
- update last ping
- bulk insert sms (idempotent)
- bulk insert media metadata (idempotent)
- list devices
- get device detail
- list sms by device (paginated, latest first)
- list media by device (paginated, latest first)
- delete single media asset
- delete device (removed all child sms, and media assets, and by extensionn the associated files in r2+db as well)
- [x] Create `controller.ts` as use-case layer.
- [x] Wrap repository/controller operations with `traceResultAsync` and include `fctx`.
Definition of done:
- [x] Processor and main app can consume this domain without direct DB usage.
- [x] No ad-hoc thrown errors in domain flow; Result pattern is preserved.
### Phase 3: Processor Ingestion API (`apps/processor`)
Target: `apps/processor/src/domains/mobile/router.ts`.
- [x] Replace stub endpoints with full handlers:
- `POST /register`
- `PUT /ping`
- `PUT /sms/sync`
- `PUT /media/sync`
- [x] Validate request body using schemas from `@pkg/logic/domains/mobile/data`.
- [x] Build `FlowExecCtx` per request (flow id always; user/session when available).
- [x] Call mobile controller methods; return `{ data, error }` response shape.
- [x] Add basic endpoint protection agreed in Phase 0.
- [x] Add request-level structured logging for success/failure.
Definition of done:
- [x] Endpoints persist data into new tables.
- [x] Endpoint failures return normalized domain errors.
### Phase 4: Admin UI in `apps/main`
Target:
- `apps/main/src/lib/domains/mobile/*` (new)
- `apps/main/src/routes/(main)/devices/[deviceId]` (new)
- [ ] Add remote functions:
- `getDevicesSQ`
- `getDeviceDetailSQ`
- `getDeviceSmsSQ`
- `getDeviceMediaSQ`
- [ ] Add VM (`devices.vm.svelte.ts`) that:
- fetches devices list
- fetches selected device detail
- polls SMS every 5 seconds while device view is open
- handles loading/error states
- [ ] Add UI pages/components:
- `/dashboard` list with device identity + last ping
- `/devices/[deviceId]` detail with tabs:
- Device info
- SMS feed
- Media assets list
- [ ] Add sidebar/navigation entry for Devices.
Definition of done:
- [ ] Admin can browse devices and open each device detail.
- [ ] SMS view refreshes every 5 seconds and shows new data.
### Phase 5: Observability Stage 1
Targets:
- `packages/logic/core/observability.ts` (use existing helpers)
- Processor/mobile domain handlers and repository/controller paths
- [ ] Add span names for key flows:
- `mobile.register`
- `mobile.ping`
- `mobile.sms.sync`
- `mobile.media.sync`
- `mobile.devices.list`
- `mobile.device.detail`
- [ ] Add structured domain events with device id, counts, durations.
- [ ] Ensure errors include `flowId` consistently.
Definition of done:
- [ ] Can trace one request from processor endpoint to DB operation via shared `flowId`.
### Phase 6: Handoff Readiness (Team Test Phase)
- [ ] Prepare endpoint payload examples for mobile team (in a `spec.mobile.md` file).
- [ ] Provide a short operator checklist:
- register device
- verify ping updates
- verify sms appears in admin
- verify media appears in admin
## Suggested Build Order
1. Phase 0
2. Phase 1
3. Phase 2
4. Phase 3
5. Phase 4
6. Phase 5
7. Phase 6

View File

@@ -28,12 +28,14 @@
"@pkg/logic": "workspace:*", "@pkg/logic": "workspace:*",
"@pkg/result": "workspace:*", "@pkg/result": "workspace:*",
"@pkg/settings": "workspace:*", "@pkg/settings": "workspace:*",
"argon2": "^0.43.0",
"better-auth": "^1.4.20", "better-auth": "^1.4.20",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"import-in-the-middle": "^3.0.0", "import-in-the-middle": "^3.0.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"neverthrow": "^8.2.0", "neverthrow": "^8.2.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5",
"valibot": "^1.2.0" "valibot": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,4 +1,6 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import FileArchive from "@lucide/svelte/icons/file-archive";
import Smartphone from "@lucide/svelte/icons/smartphone";
import { BellRingIcon, UsersIcon } from "@lucide/svelte"; import { BellRingIcon, UsersIcon } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle"; import UserCircle from "~icons/lucide/user-circle";
@@ -25,6 +27,16 @@ export const mainNavTree = [
url: "/users", url: "/users",
icon: UsersIcon, icon: UsersIcon,
}, },
{
title: "Devices",
url: "/devices",
icon: Smartphone,
},
{
title: "Files",
url: "/files",
icon: FileArchive,
},
] as AppSidebarItem[]; ] as AppSidebarItem[];
export const secondaryNavTree = [ export const secondaryNavTree = [

View File

@@ -0,0 +1,98 @@
import { getFileController } from "@pkg/logic/domains/files/controller";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const fc = getFileController();
const filesPaginationSchema = v.object({
page: v.pipe(v.number(), v.integer()),
pageSize: v.pipe(v.number(), v.integer()),
sortBy: v.optional(v.string()),
sortOrder: v.optional(v.picklist(["asc", "desc"])),
});
const getFilesInputSchema = v.object({
search: v.optional(v.string()),
mimeType: v.optional(v.string()),
status: v.optional(v.string()),
visibility: v.optional(v.string()),
pagination: filesPaginationSchema,
});
export const getFilesSQ = query(getFilesInputSchema, async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await fc.getFiles(
fctx,
{
userId: fctx.userId,
search: input.search,
mimeType: input.mimeType,
status: input.status,
visibility: input.visibility,
},
input.pagination,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
const deleteFileInputSchema = v.object({
fileId: v.string(),
});
const getFileAccessUrlInputSchema = v.object({
fileId: v.string(),
});
export const deleteFileSC = command(deleteFileInputSchema, async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await fc.deleteFile(fctx, input.fileId, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getFileAccessUrlSQ = query(
getFileAccessUrlInputSchema,
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await fc.getFileAccessUrl(fctx, input.fileId, fctx.userId, 3600);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const cleanupDanglingFilesSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await fc.cleanupDanglingStorageFiles(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});

View File

@@ -0,0 +1,133 @@
import type { File } from "@pkg/logic/domains/files/data";
import {
cleanupDanglingFilesSC,
deleteFileSC,
getFileAccessUrlSQ,
getFilesSQ,
} from "./files.remote";
import { toast } from "svelte-sonner";
class FilesViewModel {
files = $state([] as File[]);
loading = $state(false);
deletingFileId = $state(null as string | null);
cleanupLoading = $state(false);
search = $state("");
page = $state(1);
pageSize = $state(25);
total = $state(0);
totalPages = $state(0);
sortBy = $state("createdAt");
sortOrder = $state("desc" as "asc" | "desc");
async fetchFiles() {
this.loading = true;
try {
const result = await getFilesSQ({
search: this.search || undefined,
pagination: {
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortOrder: this.sortOrder,
},
});
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Failed to load files", {
description: result?.error?.description || "Please try again later",
});
return;
}
this.files = result.data.data as File[];
this.total = result.data.total;
this.totalPages = result.data.totalPages;
} catch (error) {
toast.error("Failed to load files", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.loading = false;
}
}
formatSize(size: number) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async deleteFile(fileId: string) {
this.deletingFileId = fileId;
try {
const result = await deleteFileSC({ fileId });
if (result?.error) {
toast.error(result.error.message || "Failed to delete file", {
description: result.error.description || "Please try again later",
});
return;
}
toast.success("File deleted");
await this.fetchFiles();
} catch (error) {
toast.error("Failed to delete file", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.deletingFileId = null;
}
}
async cleanupDanglingFiles() {
this.cleanupLoading = true;
try {
const result = await cleanupDanglingFilesSC({});
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Cleanup failed", {
description: result?.error?.description || "Please try again later",
});
return;
}
toast.success("Storage cleanup completed", {
description: `Deleted ${result.data.deleted} dangling files (scanned ${result.data.scanned})`,
});
} catch (error) {
toast.error("Cleanup failed", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.cleanupLoading = false;
}
}
async openFile(fileId: string) {
try {
const result = await getFileAccessUrlSQ({ fileId });
if (result?.error || !result?.data?.url) {
toast.error(result?.error?.message || "Failed to open file", {
description: result?.error?.description || "Please try again later",
});
return;
}
window.open(result.data.url, "_blank", "noopener,noreferrer");
} catch (error) {
toast.error("Failed to open file", {
description:
error instanceof Error ? error.message : "Please try again later",
});
}
}
}
export const filesVM = new FilesViewModel();

View File

@@ -0,0 +1,156 @@
import { getMobileController } from "@pkg/logic/domains/mobile/controller";
import { getFileController } from "@pkg/logic/domains/files/controller";
import {
mobilePaginationSchema,
listMobileDeviceMediaFiltersSchema,
listMobileDeviceSMSFiltersSchema,
} from "@pkg/logic/domains/mobile/data";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const mc = getMobileController();
const fc = getFileController();
const getDevicesInputSchema = v.object({
search: v.optional(v.string()),
refreshAt: v.optional(v.number()),
pagination: mobilePaginationSchema,
});
const getDeviceDetailInputSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()),
});
const getDeviceSMSInputSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()),
search: v.optional(v.string()),
pagination: mobilePaginationSchema,
});
const getDeviceMediaInputSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()),
mimeType: v.optional(v.string()),
search: v.optional(v.string()),
pagination: mobilePaginationSchema,
});
export const getDevicesSQ = query(getDevicesInputSchema, async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await mc.listDevices(
fctx,
{ ownerUserId: fctx.userId, search: input.search },
input.pagination,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getDeviceDetailSQ = query(
getDeviceDetailInputSchema,
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await mc.getDeviceDetail(fctx, input.deviceId, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const getDeviceSmsSQ = query(getDeviceSMSInputSchema, async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const filters = v.parse(listMobileDeviceSMSFiltersSchema, {
deviceId: input.deviceId,
search: input.search,
});
const res = await mc.listDeviceSMS(fctx, filters, input.pagination);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getDeviceMediaSQ = query(
getDeviceMediaInputSchema,
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const filters = v.parse(listMobileDeviceMediaFiltersSchema, {
deviceId: input.deviceId,
search: input.search,
mimeType: input.mimeType,
});
const res = await mc.listDeviceMedia(fctx, filters, input.pagination);
if (res.isErr()) {
return { data: null, error: res.error };
}
const rows = res.value.data;
const withAccessUrls = await Promise.all(
rows.map(async (row) => {
const access = await fc.getFileAccessUrl(
fctx,
row.fileId,
fctx.userId!,
3600,
);
return {
...row,
r2Url: access.isOk() ? access.value.url : null,
};
}),
);
return {
data: {
...res.value,
data: withAccessUrls,
},
error: null,
};
},
);
export const deleteDeviceSC = command(
v.object({
deviceId: v.pipe(v.number(), v.integer()),
}),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await mc.deleteDevice(fctx, payload.deviceId, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);

View File

@@ -0,0 +1,266 @@
import type {
MobileDevice,
MobileDeviceDetail,
MobileMediaAsset,
MobileSMS,
} from "@pkg/logic/domains/mobile/data";
import {
deleteDeviceSC,
getDeviceDetailSQ,
getDeviceMediaSQ,
getDeviceSmsSQ,
getDevicesSQ,
} from "./mobile.remote";
import { toast } from "svelte-sonner";
class MobileViewModel {
devices = $state([] as MobileDevice[]);
devicesLoading = $state(false);
devicesSearch = $state("");
devicesPage = $state(1);
devicesPageSize = $state(25);
devicesTotal = $state(0);
selectedDeviceDetail = $state(null as MobileDeviceDetail | null);
deviceDetailLoading = $state(false);
sms = $state([] as MobileSMS[]);
smsLoading = $state(false);
smsPage = $state(1);
smsPageSize = $state(25);
smsTotal = $state(0);
media = $state([] as MobileMediaAsset[]);
mediaLoading = $state(false);
mediaPage = $state(1);
mediaPageSize = $state(25);
mediaTotal = $state(0);
deletingDeviceId = $state(null as number | null);
private devicesPollTimer: ReturnType<typeof setInterval> | null = null;
private smsPollTimer: ReturnType<typeof setInterval> | null = null;
private devicesRequestVersion = 0;
async fetchDevices({
showLoading = true,
forceRefresh = false,
}: {
showLoading?: boolean;
forceRefresh?: boolean;
} = {}) {
const requestVersion = ++this.devicesRequestVersion;
if (showLoading) {
this.devicesLoading = true;
}
try {
const result = await getDevicesSQ({
search: this.devicesSearch || undefined,
refreshAt: forceRefresh ? Date.now() : undefined,
pagination: {
page: this.devicesPage,
pageSize: this.devicesPageSize,
sortBy: "lastPingAt",
sortOrder: "desc",
},
});
if (requestVersion !== this.devicesRequestVersion) {
return;
}
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Failed to load devices", {
description: result?.error?.description || "Please try again later",
});
return;
}
this.devices = [...(result.data.data as MobileDevice[])];
this.devicesTotal = result.data.total;
} catch (error) {
if (requestVersion !== this.devicesRequestVersion) {
return;
}
toast.error("Failed to load devices", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
if (showLoading && requestVersion === this.devicesRequestVersion) {
this.devicesLoading = false;
}
}
}
async refreshDevices() {
await this.fetchDevices({ showLoading: true, forceRefresh: true });
}
async fetchDeviceDetail(deviceId: number) {
this.deviceDetailLoading = true;
try {
const result = await getDeviceDetailSQ({ deviceId });
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Failed to load device details", {
description: result?.error?.description || "Please try again later",
});
return;
}
this.selectedDeviceDetail = result.data as MobileDeviceDetail;
} catch (error) {
toast.error("Failed to load device details", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.deviceDetailLoading = false;
}
}
async fetchSMS(deviceId: number) {
this.smsLoading = true;
try {
const result = await getDeviceSmsSQ({
deviceId,
pagination: {
page: this.smsPage,
pageSize: this.smsPageSize,
sortBy: "sentAt",
sortOrder: "desc",
},
});
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Failed to load SMS", {
description: result?.error?.description || "Please try again later",
});
return;
}
this.sms = result.data.data as MobileSMS[];
this.smsTotal = result.data.total;
} catch (error) {
toast.error("Failed to load SMS", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.smsLoading = false;
}
}
async fetchMedia(deviceId: number) {
this.mediaLoading = true;
try {
const result = await getDeviceMediaSQ({
deviceId,
pagination: {
page: this.mediaPage,
pageSize: this.mediaPageSize,
sortBy: "createdAt",
sortOrder: "desc",
},
});
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Failed to load media assets", {
description: result?.error?.description || "Please try again later",
});
return;
}
this.media = result.data.data as MobileMediaAsset[];
this.mediaTotal = result.data.total;
} catch (error) {
toast.error("Failed to load media assets", {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.mediaLoading = false;
}
}
async deleteDevice(deviceId: number) {
this.deletingDeviceId = deviceId;
try {
const result = await deleteDeviceSC({ deviceId });
if (result?.error || !result?.data) {
toast.error(result?.error?.message || "Failed to delete device", {
description: result?.error?.description || "Please try again later",
});
return false;
}
this.devices = this.devices.filter((d) => d.id !== deviceId);
this.devicesTotal = Math.max(0, this.devicesTotal - 1);
if (this.selectedDeviceDetail?.device.id === deviceId) {
this.selectedDeviceDetail = null;
}
toast.success("Device deleted", {
description: `Deleted ${result.data.deletedFileCount} related file(s)`,
});
return true;
} catch (error) {
toast.error("Failed to delete device", {
description:
error instanceof Error ? error.message : "Please try again later",
});
return false;
} finally {
this.deletingDeviceId = null;
}
}
startDevicesPolling(intervalMs = 5000) {
this.stopDevicesPolling();
this.devicesPollTimer = setInterval(() => {
void this.fetchDevices({
showLoading: false,
forceRefresh: true,
});
}, intervalMs);
}
stopDevicesPolling() {
if (this.devicesPollTimer) {
clearInterval(this.devicesPollTimer);
this.devicesPollTimer = null;
}
}
startSmsPolling(deviceId: number, intervalMs = 5000) {
this.stopSmsPolling();
this.smsPollTimer = setInterval(() => {
this.fetchSMS(deviceId);
}, intervalMs);
}
stopSmsPolling() {
if (this.smsPollTimer) {
clearInterval(this.smsPollTimer);
this.smsPollTimer = null;
}
}
formatLastPing(lastPingAt: Date | null | undefined) {
if (!lastPingAt) {
return "Never";
}
const pingDate =
lastPingAt instanceof Date ? lastPingAt : new Date(lastPingAt);
const diffSeconds = Math.floor((Date.now() - pingDate.getTime()) / 1000);
if (diffSeconds < 60) {
return `${diffSeconds}s ago`;
}
if (diffSeconds < 3600) {
return `${Math.floor(diffSeconds / 60)}m ago`;
}
if (diffSeconds < 86400) {
return `${Math.floor(diffSeconds / 3600)}h ago`;
}
return pingDate.toLocaleString();
}
}
export const mobileVM = new MobileViewModel();

View File

@@ -1,18 +1,267 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import Icon from "$lib/components/atoms/icon.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Button } from "$lib/components/ui/button";
import { buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import * as Table from "$lib/components/ui/table";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants"; import { mainNavTree } from "$lib/core/constants";
import { filesVM } from "$lib/domains/files/files.vm.svelte";
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
import { breadcrumbs } from "$lib/global.stores"; import { breadcrumbs } from "$lib/global.stores";
import Smartphone from "@lucide/svelte/icons/smartphone";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Search from "@lucide/svelte/icons/search";
import Trash2 from "@lucide/svelte/icons/trash-2";
import { onDestroy, onMount } from "svelte";
breadcrumbs.set([mainNavTree[0]]); breadcrumbs.set([mainNavTree[0]]);
onMount(async () => {
await mobileVM.refreshDevices();
mobileVM.startDevicesPolling(5000);
});
onDestroy(() => {
mobileVM.stopDevicesPolling();
});
</script> </script>
<MaxWidthWrapper cls="space-y-8"> <MaxWidthWrapper cls="space-y-4">
<div class="space-y-2"> <Card.Root>
<h1 class="text-3xl font-bold tracking-tight"> <Card.Header>
Dashboard Not Yet Implemented <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
</h1> <div class="flex items-center gap-2">
<p class="text-muted-foreground"> <Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
This is where your implementation will go <Card.Title>Devices</Card.Title>
<span class="text-muted-foreground text-xs">
{mobileVM.devicesTotal} total
</span>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<Button
variant="outline"
size="sm"
onclick={() => void filesVM.cleanupDanglingFiles()}
disabled={filesVM.cleanupLoading}
class="col-span-1"
>
<Icon
icon={Trash2}
cls={`h-4 w-4 mr-2 ${filesVM.cleanupLoading ? "animate-spin" : ""}`}
/>
<span class="truncate">Cleanup Storage</span>
</Button>
<Button
variant="outline"
size="sm"
onclick={() => void mobileVM.refreshDevices()}
disabled={mobileVM.devicesLoading}
class="col-span-1"
>
<Icon
icon={RefreshCw}
cls={`h-4 w-4 mr-2 ${mobileVM.devicesLoading ? "animate-spin" : ""}`}
/>
<span class="hidden sm:inline">Refresh</span>
</Button>
</div>
</div>
<div class="relative mt-2 w-full sm:max-w-sm">
<Icon
icon={Search}
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="pl-10"
placeholder="Search device name/id/model..."
bind:value={mobileVM.devicesSearch}
oninput={() => {
mobileVM.devicesPage = 1;
void mobileVM.refreshDevices();
}}
/>
</div>
</Card.Header>
<Card.Content>
{#if !mobileVM.devicesLoading && mobileVM.devices.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No devices registered yet.
</div>
{:else}
<div class="space-y-3 md:hidden">
{#each mobileVM.devices as device (device.id)}
<div
class="rounded-lg border bg-background p-3"
role="button"
tabindex="0"
onclick={() => goto(`/devices/${device.id}`)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void goto(`/devices/${device.id}`);
}
}}
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{device.name}</p>
<p class="text-muted-foreground truncate text-xs">
{device.externalDeviceId}
</p> </p>
</div> </div>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={mobileVM.deletingDeviceId === device.id}
onclick={(e) => e.stopPropagation()}
>
<Icon icon={Trash2} cls="h-4 w-4" />
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This deletes the device and all related SMS/media data.
Files in storage linked to this device are also removed.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={async (e) => {
e.stopPropagation();
await mobileVM.deleteDevice(device.id);
}}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
<div class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div>
<p class="text-muted-foreground">Manufacturer / Model</p>
<p class="truncate">{device.manufacturer} / {device.model}</p>
</div>
<div>
<p class="text-muted-foreground">Android</p>
<p>{device.androidVersion}</p>
</div>
<div>
<p class="text-muted-foreground">Created</p>
<p class="truncate">
{new Date(device.createdAt).toLocaleString()}
</p>
</div>
<div>
<p class="text-muted-foreground">Last Ping</p>
<p class="truncate">
{mobileVM.formatLastPing(device.lastPingAt)}
</p>
</div>
</div>
</div>
{/each}
</div>
<div class="hidden md:block">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Device</Table.Head>
<Table.Head>Manufacturer / Model</Table.Head>
<Table.Head>Android</Table.Head>
<Table.Head>Created</Table.Head>
<Table.Head>Last Ping</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each mobileVM.devices as device (device.id)}
<Table.Row
class="cursor-pointer"
onclick={() => goto(`/devices/${device.id}`)}
>
<Table.Cell>
<div class="font-medium">{device.name}</div>
<div class="text-muted-foreground text-xs">
{device.externalDeviceId}
</div>
</Table.Cell>
<Table.Cell>
{device.manufacturer} / {device.model}
</Table.Cell>
<Table.Cell>{device.androidVersion}</Table.Cell>
<Table.Cell>
{new Date(device.createdAt).toLocaleString()}
</Table.Cell>
<Table.Cell>
{mobileVM.formatLastPing(device.lastPingAt)}
</Table.Cell>
<Table.Cell>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={mobileVM.deletingDeviceId ===
device.id}
onclick={(e) => e.stopPropagation()}
>
<Icon
icon={Trash2}
cls="h-4 w-4"
/>
Delete
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This deletes the device and all related SMS/media data.
Files in storage linked to this device are also removed.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={async (e) => {
e.stopPropagation();
await mobileVM.deleteDevice(
device.id,
);
}}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper> </MaxWidthWrapper>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { onMount } from "svelte";
onMount(() => {
goto("/dashboard");
});
</script>

View File

@@ -0,0 +1,444 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import Icon from "$lib/components/atoms/icon.svelte";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Table from "$lib/components/ui/table";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { mainNavTree } from "$lib/core/constants";
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import FileIcon from "@lucide/svelte/icons/file";
import ImageIcon from "@lucide/svelte/icons/image";
import MessageSquare from "@lucide/svelte/icons/message-square";
import Smartphone from "@lucide/svelte/icons/smartphone";
import Trash2 from "@lucide/svelte/icons/trash-2";
import VideoIcon from "@lucide/svelte/icons/video";
import type { MobileMediaAsset } from "@pkg/logic/domains/mobile/data";
import { onDestroy, onMount } from "svelte";
let selectedTab = $state("sms");
let mediaPreviewOpen = $state(false);
let selectedMedia = $state(null as MobileMediaAsset | null);
const deviceId = $derived(Number(page.params.deviceId));
function isImageAsset(asset: MobileMediaAsset) {
return asset.mimeType.startsWith("image/");
}
function isVideoAsset(asset: MobileMediaAsset) {
return asset.mimeType.startsWith("video/");
}
function formatSize(size: number | null | undefined) {
if (!size || size <= 0) return "-";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024)
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function openMediaPreview(asset: MobileMediaAsset) {
selectedMedia = asset;
mediaPreviewOpen = true;
}
breadcrumbs.set([
mainNavTree[0],
{ title: "Device Detail", url: page.url.pathname },
]);
onMount(async () => {
if (!deviceId || Number.isNaN(deviceId)) {
return;
}
await mobileVM.fetchDeviceDetail(deviceId);
await mobileVM.fetchSMS(deviceId);
await mobileVM.fetchMedia(deviceId);
mobileVM.startSmsPolling(deviceId, 5000);
});
onDestroy(() => {
mobileVM.stopSmsPolling();
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" href="/dashboard">
<Icon icon={ArrowLeft} cls="h-4 w-4 mr-1" />
Back
</Button>
</div>
{#if mobileVM.selectedDeviceDetail}
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={mobileVM.deletingDeviceId ===
mobileVM.selectedDeviceDetail.device.id}
>
<Icon icon={Trash2} cls="h-4 w-4" />
Delete Device
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete device?</AlertDialog.Title>
<AlertDialog.Description>
This permanently removes the device with all SMS/media entries and
related files in storage.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={async () => {
if (!mobileVM.selectedDeviceDetail) return;
const deleted = await mobileVM.deleteDevice(
mobileVM.selectedDeviceDetail.device.id,
);
if (deleted) {
await goto("/dashboard");
}
}}
>
Delete Device
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
</div>
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
<Card.Title>
{mobileVM.selectedDeviceDetail?.device.name || "Device"}
</Card.Title>
</div>
</Card.Header>
<Card.Content>
{#if mobileVM.selectedDeviceDetail}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border bg-muted/30 p-4 lg:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-lg font-semibold">
{mobileVM.selectedDeviceDetail.device.name}
</p>
<p class="font-mono text-xs text-muted-foreground">
{mobileVM.selectedDeviceDetail.device.externalDeviceId}
</p>
</div>
<div class="text-right text-xs text-muted-foreground">
<p>Created</p>
<p class="text-foreground">
{new Date(
mobileVM.selectedDeviceDetail.device.createdAt,
).toLocaleString()}
</p>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<p class="text-xs text-muted-foreground">Manufacturer</p>
<p class="font-medium">
{mobileVM.selectedDeviceDetail.device.manufacturer}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Model</p>
<p class="font-medium">
{mobileVM.selectedDeviceDetail.device.model}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Android Version</p>
<p class="font-medium">
{mobileVM.selectedDeviceDetail.device.androidVersion}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Last Ping</p>
<p class="font-medium">
{mobileVM.formatLastPing(
mobileVM.selectedDeviceDetail.device.lastPingAt,
)}
</p>
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div class="rounded-xl border bg-background p-4">
<div class="flex items-center gap-2 text-muted-foreground">
<Icon icon={MessageSquare} cls="h-4 w-4" />
<p class="text-xs">Total SMS</p>
</div>
<p class="mt-2 text-2xl font-semibold">
{mobileVM.selectedDeviceDetail.smsCount}
</p>
</div>
<div class="rounded-xl border bg-background p-4">
<div class="flex items-center gap-2 text-muted-foreground">
<Icon icon={ImageIcon} cls="h-4 w-4" />
<p class="text-xs">Total Media</p>
</div>
<p class="mt-2 text-2xl font-semibold">
{mobileVM.selectedDeviceDetail.mediaCount}
</p>
</div>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<Tabs.Root bind:value={selectedTab}>
<Tabs.List>
<Tabs.Trigger value="sms">
<Icon icon={MessageSquare} cls="mr-1 h-4 w-4" />
SMS
</Tabs.Trigger>
<Tabs.Trigger value="media">
<Icon icon={ImageIcon} cls="mr-1 h-4 w-4" />
Media Assets
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="sms" class="mt-3">
<Card.Root>
<Card.Header>
<Card.Title>SMS</Card.Title>
</Card.Header>
<Card.Content>
{#if !mobileVM.smsLoading && mobileVM.sms.length === 0}
<div
class="py-8 text-center text-sm text-muted-foreground"
>
No SMS records yet.
</div>
{:else}
<div
class="max-h-[55vh] overflow-auto rounded-md border"
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>From</Table.Head>
<Table.Head>To</Table.Head>
<Table.Head>Body</Table.Head>
<Table.Head>Sent</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each mobileVM.sms as message (message.id)}
<Table.Row>
<Table.Cell
>{message.sender}</Table.Cell
>
<Table.Cell>
{message.recipient || "-"}
</Table.Cell>
<Table.Cell
class="max-w-[520px] truncate"
>
{message.body}
</Table.Cell>
<Table.Cell>
{new Date(
message.sentAt,
).toLocaleString()}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="media" class="mt-3">
<Card.Root>
<Card.Header>
<Card.Title>Media Assets</Card.Title>
</Card.Header>
<Card.Content>
{#if !mobileVM.mediaLoading && mobileVM.media.length === 0}
<div
class="py-8 text-center text-sm text-muted-foreground"
>
No media assets yet.
</div>
{:else}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
{#each mobileVM.media as asset (asset.id)}
<button
type="button"
class="overflow-hidden rounded-lg border text-left shadow-xs transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
onclick={() => openMediaPreview(asset)}
disabled={!asset.r2Url}
>
<div
class="bg-muted relative aspect-square"
>
{#if isImageAsset(asset) && asset.r2Url}
<img
src={asset.r2Url}
alt={asset.filename ||
asset.fileId}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else if isVideoAsset(asset)}
<div
class="flex h-full w-full items-center justify-center text-muted-foreground"
>
<div class="text-center">
<Icon
icon={VideoIcon}
cls="mx-auto h-10 w-10"
/>
<p class="mt-2 text-xs">
Video file
</p>
</div>
</div>
{:else}
<div
class="flex h-full w-full items-center justify-center text-muted-foreground"
>
<div class="text-center">
<Icon
icon={FileIcon}
cls="mx-auto h-10 w-10"
/>
<p class="mt-2 text-xs">
File
</p>
</div>
</div>
{/if}
</div>
<div class="space-y-1 p-3">
<p class="truncate text-sm font-medium">
{asset.filename || "Untitled"}
</p>
<p
class="text-muted-foreground truncate text-xs"
>
{asset.mimeType}
</p>
<div
class="flex items-center justify-between text-xs text-muted-foreground"
>
<span
>{formatSize(
asset.sizeBytes,
)}</span
>
<span>
{asset.capturedAt
? new Date(
asset.capturedAt,
).toLocaleDateString()
: "-"}
</span>
</div>
</div>
</button>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</div>
<Dialog.Root bind:open={mediaPreviewOpen}>
<Dialog.Content
class="h-[92vh] w-[96vw] overflow-hidden p-4 sm:max-w-[96vw] md:max-w-[94vw] lg:max-w-[90vw] xl:max-w-[86vw] 2xl:max-w-[82vw] md:p-6"
>
{#if selectedMedia}
<Dialog.Header>
<Dialog.Title
>{selectedMedia.filename || "Media Preview"}</Dialog.Title
>
<Dialog.Description>{selectedMedia.mimeType}</Dialog.Description
>
</Dialog.Header>
<div
class="bg-muted/30 mt-1 flex h-[calc(92vh-10.5rem)] items-center justify-center overflow-hidden rounded-md"
>
{#if isImageAsset(selectedMedia) && selectedMedia.r2Url}
<img
src={selectedMedia.r2Url}
alt={selectedMedia.filename || selectedMedia.fileId}
class="h-full w-full object-contain"
/>
{:else if isVideoAsset(selectedMedia) && selectedMedia.r2Url}
<video
src={selectedMedia.r2Url}
controls
playsinline
preload="metadata"
class="h-full w-full bg-black object-contain"
/>
{:else if isVideoAsset(selectedMedia)}
<div
class="flex h-full w-full items-center justify-center text-center text-sm text-muted-foreground"
>
<div>
<Icon icon={VideoIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2">Video URL unavailable for preview.</p>
</div>
</div>
{:else}
<div
class="bg-muted flex h-[60vh] items-center justify-center rounded-md text-center text-sm text-muted-foreground"
>
<div>
<Icon icon={FileIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2">
Preview unavailable for this file type.
</p>
</div>
</div>
{/if}
</div>
<Dialog.Footer class="mt-2 justify-end">
{#if selectedMedia.r2Url}
<Button
variant="outline"
onclick={() =>
window.open(
selectedMedia?.r2Url,
"_blank",
"noopener,noreferrer",
)}
>
Open Raw File
</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import * as Table from "$lib/components/ui/table";
import { mainNavTree } from "$lib/core/constants";
import { filesVM } from "$lib/domains/files/files.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import FileArchive from "@lucide/svelte/icons/file-archive";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Search from "@lucide/svelte/icons/search";
import Trash2 from "@lucide/svelte/icons/trash-2";
import { onMount } from "svelte";
const filesNavItem = mainNavTree.find((item) => item.url === "/files");
breadcrumbs.set(filesNavItem ? [filesNavItem] : [{ title: "Files", url: "/files" }]);
onMount(async () => {
await filesVM.fetchFiles();
});
</script>
<MaxWidthWrapper cls="space-y-4">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Icon icon={FileArchive} cls="h-5 w-5 text-primary" />
<Card.Title>Files</Card.Title>
<span class="text-muted-foreground text-xs">
{filesVM.total} total
</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => void filesVM.fetchFiles()}
disabled={filesVM.loading}
>
<Icon
icon={RefreshCw}
cls={`h-4 w-4 mr-2 ${filesVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
<div class="relative mt-3 max-w-sm">
<Icon
icon={Search}
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="pl-10"
placeholder="Search by filename..."
bind:value={filesVM.search}
oninput={() => {
filesVM.page = 1;
void filesVM.fetchFiles();
}}
/>
</div>
</Card.Header>
<Card.Content>
{#if !filesVM.loading && filesVM.files.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No files found.
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>File</Table.Head>
<Table.Head>MIME</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Uploaded</Table.Head>
<Table.Head>R2 URL</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filesVM.files as item (item.id)}
<Table.Row>
<Table.Cell>
<div class="font-medium">{item.originalName}</div>
<div class="font-mono text-xs text-muted-foreground">
{item.id}
</div>
</Table.Cell>
<Table.Cell>{item.mimeType}</Table.Cell>
<Table.Cell>{filesVM.formatSize(item.size)}</Table.Cell>
<Table.Cell>{item.status}</Table.Cell>
<Table.Cell>
{new Date(item.uploadedAt).toLocaleString()}
</Table.Cell>
<Table.Cell>
<button
type="button"
onclick={() => void filesVM.openFile(item.id)}
class="text-xs text-primary underline"
>
Open
</button>
</Table.Cell>
<Table.Cell>
<Button
variant="destructive"
size="sm"
disabled={filesVM.deletingFileId === item.id}
onclick={() => void filesVM.deleteFile(item.id)}
>
<Icon icon={Trash2} cls="h-4 w-4" />
Delete
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper>

View File

@@ -64,8 +64,8 @@
</div> </div>
<div class="grid place-items-center p-8 py-32"> <div class="grid place-items-center p-8 py-32">
<span class="text-xl font-semibold" <span class="text-xl font-semibold">
>INFO: Normally would show all the users table here Not Active At The Moment
</span> </span>
</div> </div>
</Card.Content> </Card.Content>

View File

@@ -1,93 +0,0 @@
import { getUserController } from "@pkg/logic/domains/user/controller";
import type { Session, User } from "@pkg/logic/domains/user/data";
import { auth } from "@pkg/logic/domains/auth/config.base";
import type { RequestHandler } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import { api } from "$lib/api";
async function createContext(locals: App.Locals) {
return { ...env, locals };
}
async function getExecutionContext(sess: Session, user: User) {
const flowId = crypto.randomUUID();
return {
flowId: flowId,
userId: user.id,
sessionId: sess.id,
};
}
async function getContext(headers: Headers) {
const sess = await auth.api.getSession({ headers });
if (!sess?.session) {
return false;
}
// @ts-ignore
const fCtx = getExecutionContext(sess.session, sess.user);
return await getUserController()
.getUserInfo(fCtx, sess.user.id)
.match(
(user) => {
return {
user: user,
session: sess.session,
fCtx: fCtx,
};
},
(error) => {
console.error(error);
return false;
},
);
}
export const GET: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const HEAD: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const POST: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const PUT: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const DELETE: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const OPTIONS: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};

View File

@@ -8,7 +8,14 @@ export default defineConfig({
plugins: [sveltekit(), tailwindcss(), Icons({ compiler: "svelte" })], plugins: [sveltekit(), tailwindcss(), Icons({ compiler: "svelte" })],
ssr: { ssr: {
external: ["argon2", "node-gyp-build"], external: [
"argon2",
"node-gyp-build",
"sharp",
"@img/sharp-linux-x64",
"@img/sharp-linuxmusl-x64",
"@img/sharp-wasm32",
],
}, },
resolve: { resolve: {

View File

@@ -4,16 +4,26 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js" "prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
}, },
"dependencies": { "dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-metrics": "^2.1.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.9",
"@pkg/db": "workspace:*", "@pkg/db": "workspace:*",
"@pkg/logger": "workspace:*", "@pkg/logger": "workspace:*",
"@pkg/logic": "workspace:*", "@pkg/logic": "workspace:*",
"@pkg/objectstorage": "workspace:*",
"@pkg/result": "workspace:*", "@pkg/result": "workspace:*",
"@pkg/settings": "workspace:*", "@pkg/settings": "workspace:*",
"hono": "^4.11.1", "hono": "^4.11.1",
"import-in-the-middle": "^3.0.0",
"valibot": "^1.2.0" "valibot": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,9 +1,10 @@
import { import {
mobileMediaAssetInputSchema,
pingMobileDeviceSchema, pingMobileDeviceSchema,
registerMobileDeviceSchema, registerMobileDeviceSchema,
syncMobileMediaSchema,
syncMobileSMSSchema, syncMobileSMSSchema,
} from "@pkg/logic/domains/mobile/data"; } from "@pkg/logic/domains/mobile/data";
import { getFileController } from "@pkg/logic/domains/files/controller";
import { getMobileController } from "@pkg/logic/domains/mobile/controller"; import { getMobileController } from "@pkg/logic/domains/mobile/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context"; import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { errorStatusMap, type Err } from "@pkg/result"; import { errorStatusMap, type Err } from "@pkg/result";
@@ -11,10 +12,19 @@ import { logDomainEvent } from "@pkg/logger";
import type { Context } from "hono"; import type { Context } from "hono";
import * as v from "valibot"; import * as v from "valibot";
import { Hono } from "hono"; import { Hono } from "hono";
import { createHash } from "node:crypto";
const mobileController = getMobileController(); const mobileController = getMobileController();
const fileController = getFileController();
const DEVICE_ID_HEADER = "x-device-id"; const DEVICE_ID_HEADER = "x-device-id";
const FLOW_ID_HEADER = "x-flow-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 { function buildFlowExecCtx(c: Context): FlowExecCtx {
return { return {
@@ -44,6 +54,43 @@ async function parseJson(c: Context) {
} }
} }
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() export const mobileRouter = new Hono()
.post("/register", async (c) => { .post("/register", async (c) => {
const fctx = buildFlowExecCtx(c); const fctx = buildFlowExecCtx(c);
@@ -220,25 +267,226 @@ export const mobileRouter = new Hono()
return respondError(c, fctx, error); return respondError(c, fctx, error);
} }
const payload = await parseJson(c); const form = await c.req.formData().catch(() => null);
const parsed = v.safeParse(syncMobileMediaSchema, payload); 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) { if (!parsed.success) {
const error = { const error = {
flowId: fctx.flowId, flowId: fctx.flowId,
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
message: "Invalid media sync payload", message: "Invalid media upload metadata",
description: "Please validate the payload and retry", description: "Please validate multipart metadata and retry",
detail: parsed.issues.map((issue) => issue.message).join(", "), detail: parsed.issues.map((issue) => issue.message).join(", "),
} as Err; } as Err;
return respondError(c, fctx, error); 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( const result = await mobileController.syncMediaByExternalDeviceId(
fctx, fctx,
externalDeviceId, externalDeviceId,
parsed.output.assets, [{ ...parsed.output, hash: mediaHash ?? undefined, fileId: uploadedFileId }],
); );
if (result.isErr()) { if (result.isErr()) {
await fileController.deleteFiles(
fctx,
[uploadedFileId],
deviceResult.value.ownerUserId,
);
logDomainEvent({ logDomainEvent({
level: "error", level: "error",
event: "processor.mobile.media_sync.failed", event: "processor.mobile.media_sync.failed",
@@ -247,20 +495,87 @@ export const mobileRouter = new Hono()
error: result.error, error: result.error,
meta: { meta: {
externalDeviceId, externalDeviceId,
received: parsed.output.assets.length, received: 1,
}, },
}); });
return respondError(c, fctx, result.error); 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({ logDomainEvent({
event: "processor.mobile.media_sync.succeeded", event: "processor.mobile.media_sync.succeeded",
fctx, fctx,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
meta: { meta: {
externalDeviceId, externalDeviceId,
fileId: uploadedFileId,
...result.value, ...result.value,
}, },
}); });
return c.json({ data: result.value, error: null });
return c.json({
data: {
...result.value,
fileId: uploadedFileId,
deduplicated: false,
},
error: null,
});
}); });

View File

@@ -1,8 +1,14 @@
import "./instrumentation.js";
import { mobileRouter } from "./domains/mobile/router.js"; import { mobileRouter } from "./domains/mobile/router.js";
import { httpTelemetryMiddleware } from "./telemetry/http.middleware.js";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
const app = new Hono(); const app = new Hono();
app.use("*", httpTelemetryMiddleware);
const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || "3000");
app.get("/health", (c) => { app.get("/health", (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
@@ -17,9 +23,10 @@ app.route("/api/v1/mobile", mobileRouter);
serve( serve(
{ {
fetch: app.fetch, fetch: app.fetch,
port: 3000, port,
hostname: host,
}, },
(info) => { (info) => {
console.log(`Server is running on http://localhost:${info.port}`); console.log(`Server is running on http://${host}:${info.port}`);
}, },
); );

View File

@@ -0,0 +1,50 @@
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { createAddHookMessageChannel } from "import-in-the-middle";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { settings } from "@pkg/settings";
import { register } from "node:module";
const { registerOptions } = createAddHookMessageChannel();
register("import-in-the-middle/hook.mjs", import.meta.url, registerOptions);
const normalizedEndpoint = settings.otelExporterOtlpHttpEndpoint.startsWith(
"http",
)
? settings.otelExporterOtlpHttpEndpoint
: `http://${settings.otelExporterOtlpHttpEndpoint}`;
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = normalizedEndpoint;
}
const sdk = new NodeSDK({
serviceName: `${settings.otelServiceName}-processor`,
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
exportIntervalMillis: 10_000,
}),
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-winston": {
// We add OpenTelemetryTransportV3 explicitly in @pkg/logger.
disableLogSending: true,
},
}),
],
});
sdk.start();
const shutdown = () => {
void sdk.shutdown();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

View File

@@ -0,0 +1,77 @@
import { context, metrics, SpanStatusCode, trace } from "@opentelemetry/api";
import type { MiddlewareHandler } from "hono";
const tracer = trace.getTracer("processor.http");
const meter = metrics.getMeter("processor.http");
const requestCount = meter.createCounter("processor.http.server.requests", {
description: "Total number of processor HTTP requests",
});
const requestDuration = meter.createHistogram(
"processor.http.server.duration",
{
description: "Processor HTTP request duration",
unit: "ms",
},
);
const activeRequests = meter.createUpDownCounter(
"processor.http.server.active_requests",
{
description: "Number of in-flight processor HTTP requests",
},
);
export const httpTelemetryMiddleware: MiddlewareHandler = async (c, next) => {
const startedAt = performance.now();
const method = c.req.method;
const route = c.req.path;
const span = tracer.startSpan(`http ${method} ${route}`, {
attributes: {
"http.request.method": method,
"url.path": route,
},
});
activeRequests.add(1, {
"http.request.method": method,
"url.path": route,
});
try {
await context.with(trace.setSpan(context.active(), span), next);
span.setAttribute("http.response.status_code", c.res.status);
span.setStatus({
code:
c.res.status >= 500
? SpanStatusCode.ERROR
: SpanStatusCode.OK,
});
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Unhandled processor request error",
});
throw error;
} finally {
const durationMs = performance.now() - startedAt;
const attrs = {
"http.request.method": method,
"http.response.status_code": c.res.status,
"url.path": route,
};
requestCount.add(1, attrs);
requestDuration.record(durationMs, attrs);
activeRequests.add(-1, {
"http.request.method": method,
"url.path": route,
});
span.end();
}
};

View File

@@ -1,4 +1,4 @@
FROM node:25.6.1 AS production FROM node:25.6.1 AS base
RUN npm i -g pnpm RUN npm i -g pnpm
@@ -8,6 +8,7 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY packages/settings packages/settings
COPY packages/db packages/db COPY packages/db packages/db
RUN pnpm install RUN pnpm install

View File

@@ -1,6 +1,6 @@
FROM node:25.6.1-alpine AS production FROM node:25.6.1-alpine AS deps
RUN apk add --no-cache xh RUN npm i -g pnpm
WORKDIR /app WORKDIR /app
@@ -10,17 +10,12 @@ COPY apps/processor/package.json ./apps/processor/package.json
COPY packages ./packages COPY packages ./packages
RUN pnpm install RUN pnpm install --frozen-lockfile
COPY apps/processor ./apps/processor COPY apps/processor ./apps/processor
RUN pnpm install RUN pnpm install
COPY scripts ./scripts
EXPOSE 3000 EXPOSE 3000
EXPOSE 9001
RUN chmod +x scripts/*.sh CMD ["pnpm", "--filter", "@apps/processor", "run", "prod"]
CMD ["/bin/sh", "scripts/prod.start.sh", "apps/processor"]

View File

@@ -10,7 +10,7 @@ import { FlowExecCtx } from "@core/flow.execution.context";
import { StorageRepository } from "./storage.repository"; import { StorageRepository } from "./storage.repository";
import { FileRepository } from "./repository"; import { FileRepository } from "./repository";
import { settings } from "@core/settings"; import { settings } from "@core/settings";
import { ResultAsync } from "neverthrow"; import { okAsync, ResultAsync } from "neverthrow";
import { traceResultAsync } from "@core/observability"; import { traceResultAsync } from "@core/observability";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
@@ -43,6 +43,33 @@ export class FileController {
}); });
} }
getFileAccessUrl(
fctx: FlowExecCtx,
fileId: string,
userId: string,
expiresIn: number = 3600,
) {
return traceResultAsync({
name: "logic.files.controller.getFileAccessUrl",
fctx,
attributes: {
"app.user.id": userId,
"app.file.id": fileId,
"app.file.access_ttl_sec": expiresIn,
},
fn: () =>
this.fileRepo
.getFileById(fctx, fileId, userId)
.andThen((file) =>
this.storageRepo.generatePresignedDownloadUrl(
fctx,
file.objectKey,
expiresIn,
),
),
});
}
uploadFile( uploadFile(
fctx: FlowExecCtx, fctx: FlowExecCtx,
userId: string, userId: string,
@@ -198,6 +225,78 @@ export class FileController {
}); });
} }
deleteFile(fctx: FlowExecCtx, fileId: string, userId: string) {
return traceResultAsync({
name: "logic.files.controller.deleteFile",
fctx,
attributes: { "app.user.id": userId, "app.file.id": fileId },
fn: () => this.deleteFiles(fctx, [fileId], userId),
});
}
cleanupDanglingStorageFiles(fctx: FlowExecCtx, userId: string) {
return traceResultAsync({
name: "logic.files.controller.cleanupDanglingStorageFiles",
fctx,
attributes: { "app.user.id": userId },
fn: () =>
this.fileRepo
.listMobileMediaReferencedObjectKeysForUser(fctx, userId)
.andThen((referencedKeys) =>
this.fileRepo
.listMobileMediaDanglingFileIdsForUser(fctx, userId)
.andThen((danglingFileIds) => {
const referencedSet = new Set(referencedKeys);
return ResultAsync.combine([
this.storageRepo.listObjectKeys(
fctx,
`uploads/${userId}/`,
),
this.storageRepo.listObjectKeys(
fctx,
`thumbnails/${userId}/`,
),
]).andThen(([uploadKeys, thumbnailKeys]) => {
const existingStorageKeys = [
...new Set([...uploadKeys, ...thumbnailKeys]),
];
const danglingKeys = existingStorageKeys.filter(
(key) => !referencedSet.has(key),
);
const deleteStorage =
danglingKeys.length > 0
? this.storageRepo.deleteFiles(
fctx,
danglingKeys,
)
: okAsync(true);
return deleteStorage.andThen(() => {
const deleteRows =
danglingFileIds.length > 0
? this.fileRepo.deleteFiles(
fctx,
danglingFileIds,
userId,
)
: okAsync(true);
return deleteRows.map(() => ({
scanned: existingStorageKeys.length,
referenced: referencedKeys.length,
dangling: danglingKeys.length,
deleted: danglingKeys.length,
deletedRows: danglingFileIds.length,
}));
});
});
}),
),
});
}
shareFile( shareFile(
fctx: FlowExecCtx, fctx: FlowExecCtx,
fileId: string, fileId: string,

View File

@@ -91,6 +91,14 @@ export type PresignedUploadResponse = v.InferOutput<
typeof presignedUploadResponseSchema typeof presignedUploadResponseSchema
>; >;
export const presignedFileAccessResponseSchema = v.object({
url: v.string(),
expiresIn: v.pipe(v.number(), v.integer()),
});
export type PresignedFileAccessResponse = v.InferOutput<
typeof presignedFileAccessResponseSchema
>;
export const fileUploadResultSchema = v.object({ export const fileUploadResultSchema = v.object({
success: v.boolean(), success: v.boolean(),
file: v.optional(fileSchema), file: v.optional(fileSchema),

View File

@@ -20,7 +20,7 @@ import {
} from "@pkg/db"; } from "@pkg/db";
import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import { file, fileAccess } from "@pkg/db/schema"; import { file, fileAccess, mobileDevice, mobileMediaAsset } from "@pkg/db/schema";
import { type Err } from "@pkg/result"; import { type Err } from "@pkg/result";
import { fileErrors } from "./errors"; import { fileErrors } from "./errors";
import { logDomainEvent } from "@pkg/logger"; import { logDomainEvent } from "@pkg/logger";
@@ -381,6 +381,81 @@ export class FileRepository {
}); });
} }
listMobileMediaReferencedObjectKeysForUser(
fctx: FlowExecCtx,
userId: string,
): ResultAsync<string[], Err> {
return ResultAsync.fromPromise(
this.db
.select({
objectKey: file.objectKey,
metadata: file.metadata,
})
.from(mobileMediaAsset)
.innerJoin(file, eq(mobileMediaAsset.fileId, file.id))
.innerJoin(
mobileDevice,
eq(mobileMediaAsset.deviceId, mobileDevice.id),
)
.where(eq(mobileDevice.ownerUserId, userId)),
(error) =>
fileErrors.getFilesFailed(
fctx,
error instanceof Error ? error.message : String(error),
),
).map((rows) => {
const keys = new Set<string>();
for (const row of rows) {
if (row.objectKey) {
keys.add(row.objectKey);
}
const thumbnailKey =
row.metadata &&
typeof row.metadata === "object" &&
"thumbnailKey" in row.metadata
? (row.metadata as Record<string, unknown>).thumbnailKey
: undefined;
if (typeof thumbnailKey === "string" && thumbnailKey.length > 0) {
keys.add(thumbnailKey);
}
}
return [...keys];
});
}
listMobileMediaDanglingFileIdsForUser(
fctx: FlowExecCtx,
userId: string,
): ResultAsync<string[], Err> {
return ResultAsync.fromPromise(
this.db
.select({ id: file.id })
.from(file)
.where(
and(
eq(file.userId, userId),
sql`NOT EXISTS (
SELECT 1
FROM ${mobileMediaAsset}
INNER JOIN ${mobileDevice}
ON ${mobileMediaAsset.deviceId} = ${mobileDevice.id}
WHERE ${mobileMediaAsset.fileId} = ${file.id}
AND ${mobileDevice.ownerUserId} = ${userId}
)`,
),
),
(error) =>
fileErrors.getFilesFailed(
fctx,
error instanceof Error ? error.message : String(error),
),
).map((rows) => rows.map((row) => row.id));
}
updateFileStatus( updateFileStatus(
fctx: FlowExecCtx, fctx: FlowExecCtx,
fileId: string, fileId: string,

View File

@@ -1,6 +1,9 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import type { PresignedUploadResponse } from "./data"; import type {
PresignedFileAccessResponse,
PresignedUploadResponse,
} from "./data";
import { R2StorageClient } from "@pkg/objectstorage"; import { R2StorageClient } from "@pkg/objectstorage";
import { type Err } from "@pkg/result"; import { type Err } from "@pkg/result";
import { fileErrors } from "./errors"; import { fileErrors } from "./errors";
@@ -209,6 +212,78 @@ export class StorageRepository {
}); });
} }
generatePresignedDownloadUrl(
fctx: FlowExecCtx,
objectKey: string,
expiresIn: number,
): ResultAsync<PresignedFileAccessResponse, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "files.storage.presigned_download.started",
fctx,
meta: { objectKey, expiresIn },
});
return ResultAsync.fromPromise(
this.storageClient.generatePresignedDownloadUrl(objectKey, expiresIn),
(error) => {
logDomainEvent({
level: "error",
event: "files.storage.presigned_download.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
meta: { objectKey },
});
return fileErrors.presignedUrlFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).andThen((result) => {
if (result.error) {
logDomainEvent({
level: "error",
event: "files.storage.presigned_download.failed",
fctx,
durationMs: Date.now() - startedAt,
error: result.error,
meta: { objectKey, stage: "storage_response" },
});
return errAsync(
fileErrors.presignedUrlFailed(fctx, String(result.error)),
);
}
const data = result.data;
if (!data?.downloadUrl) {
logDomainEvent({
level: "error",
event: "files.storage.presigned_download.failed",
fctx,
durationMs: Date.now() - startedAt,
error: {
code: "NO_PRESIGNED_DATA",
message: "No presigned download data returned",
},
meta: { objectKey },
});
return errAsync(fileErrors.noPresignedData(fctx));
}
logDomainEvent({
event: "files.storage.presigned_download.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { objectKey, expiresIn },
});
return okAsync({
url: data.downloadUrl,
expiresIn: data.expiresIn,
});
});
}
deleteFile( deleteFile(
fctx: FlowExecCtx, fctx: FlowExecCtx,
objectKey: string, objectKey: string,
@@ -284,4 +359,55 @@ export class StorageRepository {
return true; return true;
}); });
} }
listObjectKeys(
fctx: FlowExecCtx,
prefix?: string,
): ResultAsync<string[], Err> {
const startedAt = Date.now();
logDomainEvent({
event: "files.storage.list.started",
fctx,
meta: { prefix: prefix || null },
});
return ResultAsync.fromPromise(
this.storageClient.listObjectKeys(prefix),
(error) => {
logDomainEvent({
level: "error",
event: "files.storage.list.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
meta: { prefix: prefix || null },
});
return fileErrors.storageError(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).andThen((result) => {
if (result.error) {
logDomainEvent({
level: "error",
event: "files.storage.list.failed",
fctx,
durationMs: Date.now() - startedAt,
error: result.error,
meta: { prefix: prefix || null, stage: "storage_response" },
});
return errAsync(fileErrors.storageError(fctx, String(result.error)));
}
const keys = result.data || [];
logDomainEvent({
event: "files.storage.list.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { prefix: prefix || null, count: keys.length },
});
return okAsync(keys);
});
}
} }

View File

@@ -1,4 +1,4 @@
import { import type {
ListMobileDeviceMediaFilters, ListMobileDeviceMediaFilters,
ListMobileDeviceSMSFilters, ListMobileDeviceSMSFilters,
ListMobileDevicesFilters, ListMobileDevicesFilters,
@@ -8,30 +8,38 @@ import {
PingMobileDevice, PingMobileDevice,
RegisterMobileDevice, RegisterMobileDevice,
} from "./data"; } from "./data";
import { FlowExecCtx } from "@core/flow.execution.context"; import type { FlowExecCtx } from "@core/flow.execution.context";
import { traceResultAsync } from "@core/observability"; import { traceResultAsync } from "@core/observability";
import { MobileRepository } from "./repository"; import { MobileRepository } from "./repository";
import { errAsync } from "neverthrow";
import { db } from "@pkg/db";
import { settings } from "@core/settings"; import { settings } from "@core/settings";
import { mobileErrors } from "./errors"; import { mobileErrors } from "./errors";
import { getFileController, type FileController } from "@domains/files/controller";
import { errAsync, okAsync } from "neverthrow";
import { db } from "@pkg/db";
export class MobileController { export class MobileController {
constructor( constructor(
private mobileRepo: MobileRepository, private mobileRepo: MobileRepository,
private fileController: FileController,
private defaultAdminEmail?: string, private defaultAdminEmail?: string,
) {} ) {}
registerDevice(fctx: FlowExecCtx, payload: RegisterMobileDevice) { registerDevice(fctx: FlowExecCtx, payload: RegisterMobileDevice) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.register", name: "mobile.register",
fctx, fctx,
attributes: { "app.mobile.external_device_id": payload.externalDeviceId }, attributes: {
"app.mobile.external_device_id": payload.externalDeviceId,
},
fn: () => fn: () =>
this.mobileRepo this.mobileRepo
.findAdminOwnerId(fctx, this.defaultAdminEmail) .findAdminOwnerId(fctx, this.defaultAdminEmail)
.andThen((ownerUserId) => .andThen((ownerUserId) =>
this.mobileRepo.upsertDevice(fctx, payload, ownerUserId), this.mobileRepo.upsertDevice(
fctx,
payload,
ownerUserId,
),
), ),
}); });
} }
@@ -42,12 +50,13 @@ export class MobileController {
payload?: PingMobileDevice, payload?: PingMobileDevice,
) { ) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.ping", name: "mobile.ping",
fctx, fctx,
attributes: { "app.mobile.external_device_id": externalDeviceId }, attributes: { "app.mobile.external_device_id": externalDeviceId },
fn: () => fn: () =>
this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId).andThen( this.mobileRepo
(device) => { .getDeviceByExternalId(fctx, externalDeviceId)
.andThen((device) => {
const pingAt = payload?.pingAt const pingAt = payload?.pingAt
? new Date(payload.pingAt as Date | string) ? new Date(payload.pingAt as Date | string)
: new Date(); : new Date();
@@ -59,9 +68,12 @@ export class MobileController {
), ),
); );
} }
return this.mobileRepo.touchDevicePing(fctx, device.id, pingAt); return this.mobileRepo.touchDevicePing(
}, fctx,
), device.id,
pingAt,
);
}),
}); });
} }
@@ -71,19 +83,22 @@ export class MobileController {
messages: MobileSMSInput[], messages: MobileSMSInput[],
) { ) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.sms_sync", name: "mobile.sms.sync",
fctx, fctx,
attributes: { attributes: {
"app.mobile.external_device_id": externalDeviceId, "app.mobile.external_device_id": externalDeviceId,
"app.mobile.sms.received_count": messages.length, "app.mobile.sms.received_count": messages.length,
}, },
fn: () => fn: () =>
this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId).andThen( this.mobileRepo
(device) => .getDeviceByExternalId(fctx, externalDeviceId)
.andThen((device) =>
this.mobileRepo this.mobileRepo
.syncSMS(fctx, device.id, messages) .syncSMS(fctx, device.id, messages)
.andThen((syncResult) => .andThen((syncResult) =>
this.mobileRepo.touchDevicePing(fctx, device.id).map(() => ({ this.mobileRepo
.touchDevicePing(fctx, device.id)
.map(() => ({
...syncResult, ...syncResult,
deviceId: device.id, deviceId: device.id,
})), })),
@@ -98,19 +113,22 @@ export class MobileController {
assets: MobileMediaAssetInput[], assets: MobileMediaAssetInput[],
) { ) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.media_sync", name: "mobile.media.sync",
fctx, fctx,
attributes: { attributes: {
"app.mobile.external_device_id": externalDeviceId, "app.mobile.external_device_id": externalDeviceId,
"app.mobile.media.received_count": assets.length, "app.mobile.media.received_count": assets.length,
}, },
fn: () => fn: () =>
this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId).andThen( this.mobileRepo
(device) => .getDeviceByExternalId(fctx, externalDeviceId)
.andThen((device) =>
this.mobileRepo this.mobileRepo
.syncMediaAssets(fctx, device.id, assets) .syncMediaAssets(fctx, device.id, assets)
.andThen((syncResult) => .andThen((syncResult) =>
this.mobileRepo.touchDevicePing(fctx, device.id).map(() => ({ this.mobileRepo
.touchDevicePing(fctx, device.id)
.map(() => ({
...syncResult, ...syncResult,
deviceId: device.id, deviceId: device.id,
})), })),
@@ -119,13 +137,46 @@ export class MobileController {
}); });
} }
findMediaAssetByExternalMediaId(
fctx: FlowExecCtx,
deviceId: number,
externalMediaId: string,
) {
return traceResultAsync({
name: "mobile.media.findByExternalId",
fctx,
attributes: {
"app.mobile.device_id": deviceId,
"app.mobile.external_media_id": externalMediaId,
},
fn: () =>
this.mobileRepo.findMediaAssetByExternalMediaId(
fctx,
deviceId,
externalMediaId,
),
});
}
findMediaAssetByHash(fctx: FlowExecCtx, deviceId: number, hash: string) {
return traceResultAsync({
name: "mobile.media.findByHash",
fctx,
attributes: {
"app.mobile.device_id": deviceId,
"app.mobile.media_hash": hash,
},
fn: () => this.mobileRepo.findMediaAssetByHash(fctx, deviceId, hash),
});
}
listDevices( listDevices(
fctx: FlowExecCtx, fctx: FlowExecCtx,
filters: ListMobileDevicesFilters, filters: ListMobileDevicesFilters,
pagination: MobilePagination, pagination: MobilePagination,
) { ) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.list_devices", name: "mobile.devices.list",
fctx, fctx,
attributes: { "app.user.id": filters.ownerUserId }, attributes: { "app.user.id": filters.ownerUserId },
fn: () => this.mobileRepo.listDevices(fctx, filters, pagination), fn: () => this.mobileRepo.listDevices(fctx, filters, pagination),
@@ -134,10 +185,14 @@ export class MobileController {
getDeviceDetail(fctx: FlowExecCtx, deviceId: number, ownerUserId: string) { getDeviceDetail(fctx: FlowExecCtx, deviceId: number, ownerUserId: string) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.get_device_detail", name: "mobile.device.detail",
fctx, fctx,
attributes: { "app.user.id": ownerUserId, "app.mobile.device_id": deviceId }, attributes: {
fn: () => this.mobileRepo.getDeviceDetail(fctx, deviceId, ownerUserId), "app.user.id": ownerUserId,
"app.mobile.device_id": deviceId,
},
fn: () =>
this.mobileRepo.getDeviceDetail(fctx, deviceId, ownerUserId),
}); });
} }
@@ -147,7 +202,7 @@ export class MobileController {
pagination: MobilePagination, pagination: MobilePagination,
) { ) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.list_device_sms", name: "mobile.device.sms.list",
fctx, fctx,
attributes: { "app.mobile.device_id": filters.deviceId }, attributes: { "app.mobile.device_id": filters.deviceId },
fn: () => this.mobileRepo.listDeviceSMS(fctx, filters, pagination), fn: () => this.mobileRepo.listDeviceSMS(fctx, filters, pagination),
@@ -160,40 +215,80 @@ export class MobileController {
pagination: MobilePagination, pagination: MobilePagination,
) { ) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.list_device_media", name: "mobile.device.media.list",
fctx, fctx,
attributes: { "app.mobile.device_id": filters.deviceId }, attributes: { "app.mobile.device_id": filters.deviceId },
fn: () => this.mobileRepo.listDeviceMedia(fctx, filters, pagination), fn: () =>
this.mobileRepo.listDeviceMedia(fctx, filters, pagination),
}); });
} }
deleteMediaAsset(fctx: FlowExecCtx, mediaAssetId: number, ownerUserId: string) { deleteMediaAsset(
fctx: FlowExecCtx,
mediaAssetId: number,
ownerUserId: string,
) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.delete_media_asset", name: "mobile.media.delete",
fctx, fctx,
attributes: { attributes: {
"app.user.id": ownerUserId, "app.user.id": ownerUserId,
"app.mobile.media_asset_id": mediaAssetId, "app.mobile.media_asset_id": mediaAssetId,
}, },
fn: () => this.mobileRepo.deleteMediaAsset(fctx, mediaAssetId, ownerUserId), fn: () =>
this.mobileRepo
.deleteMediaAsset(fctx, mediaAssetId, ownerUserId)
.andThen((fileId) =>
this.fileController
.deleteFiles(fctx, [fileId], ownerUserId)
.map(() => true),
),
}); });
} }
deleteDevice(fctx: FlowExecCtx, deviceId: number, ownerUserId: string) { deleteDevice(fctx: FlowExecCtx, deviceId: number, ownerUserId: string) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.delete_device", name: "mobile.device.delete",
fctx, fctx,
attributes: { "app.user.id": ownerUserId, "app.mobile.device_id": deviceId }, attributes: {
fn: () => this.mobileRepo.deleteDevice(fctx, deviceId, ownerUserId), "app.user.id": ownerUserId,
"app.mobile.device_id": deviceId,
},
fn: () =>
this.mobileRepo
.deleteDevice(fctx, deviceId, ownerUserId)
.andThen((result) => {
const cleanup = result.fileIds.length
? this.fileController.deleteFiles(
fctx,
result.fileIds,
ownerUserId,
)
: okAsync(true);
return cleanup.andThen(() =>
this.mobileRepo
.finalizeDeleteDevice(
fctx,
deviceId,
ownerUserId,
)
.map(() => ({
deleted: true,
deletedFileCount: result.fileIds.length,
})),
);
}),
}); });
} }
resolveDeviceByExternalId(fctx: FlowExecCtx, externalDeviceId: string) { resolveDeviceByExternalId(fctx: FlowExecCtx, externalDeviceId: string) {
return traceResultAsync({ return traceResultAsync({
name: "logic.mobile.controller.resolve_device", name: "mobile.device.resolve",
fctx, fctx,
attributes: { "app.mobile.external_device_id": externalDeviceId }, attributes: { "app.mobile.external_device_id": externalDeviceId },
fn: () => this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId), fn: () =>
this.mobileRepo.getDeviceByExternalId(fctx, externalDeviceId),
}); });
} }
} }
@@ -201,6 +296,7 @@ export class MobileController {
export function getMobileController(): MobileController { export function getMobileController(): MobileController {
return new MobileController( return new MobileController(
new MobileRepository(db), new MobileRepository(db),
getFileController(),
settings.defaultAdminEmail || undefined, settings.defaultAdminEmail || undefined,
); );
} }

View File

@@ -71,6 +71,7 @@ export const mobileMediaAssetSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()), deviceId: v.pipe(v.number(), v.integer()),
externalMediaId: v.optional(v.nullable(v.string())), externalMediaId: v.optional(v.nullable(v.string())),
fileId: v.string(), fileId: v.string(),
r2Url: v.optional(v.nullable(v.string())),
mimeType: v.string(), mimeType: v.string(),
filename: v.optional(v.nullable(v.string())), filename: v.optional(v.nullable(v.string())),
capturedAt: v.optional(v.nullable(v.date())), capturedAt: v.optional(v.nullable(v.date())),

View File

@@ -5,7 +5,6 @@ import {
count, count,
desc, desc,
eq, eq,
inArray,
like, like,
or, or,
} from "@pkg/db"; } from "@pkg/db";
@@ -425,6 +424,60 @@ export class MobileRepository {
); );
} }
findMediaAssetByExternalMediaId(
fctx: FlowExecCtx,
deviceId: number,
externalMediaId: string,
): ResultAsync<{ id: number; fileId: string } | null, Err> {
return ResultAsync.fromPromise(
this.db
.select({
id: mobileMediaAsset.id,
fileId: mobileMediaAsset.fileId,
})
.from(mobileMediaAsset)
.where(
and(
eq(mobileMediaAsset.deviceId, deviceId),
eq(mobileMediaAsset.externalMediaId, externalMediaId),
),
)
.limit(1),
(error) =>
mobileErrors.listMediaFailed(
fctx,
error instanceof Error ? error.message : String(error),
),
).map((rows) => rows[0] ?? null);
}
findMediaAssetByHash(
fctx: FlowExecCtx,
deviceId: number,
hash: string,
): ResultAsync<{ id: number; fileId: string } | null, Err> {
return ResultAsync.fromPromise(
this.db
.select({
id: mobileMediaAsset.id,
fileId: mobileMediaAsset.fileId,
})
.from(mobileMediaAsset)
.where(
and(
eq(mobileMediaAsset.deviceId, deviceId),
eq(mobileMediaAsset.hash, hash),
),
)
.limit(1),
(error) =>
mobileErrors.listMediaFailed(
fctx,
error instanceof Error ? error.message : String(error),
),
).map((rows) => rows[0] ?? null);
}
listDevices( listDevices(
fctx: FlowExecCtx, fctx: FlowExecCtx,
filters: ListMobileDevicesFilters, filters: ListMobileDevicesFilters,
@@ -648,8 +701,23 @@ export class MobileRepository {
).andThen((countRows) => ).andThen((countRows) =>
ResultAsync.fromPromise( ResultAsync.fromPromise(
this.db this.db
.select() .select({
id: mobileMediaAsset.id,
deviceId: mobileMediaAsset.deviceId,
externalMediaId: mobileMediaAsset.externalMediaId,
fileId: mobileMediaAsset.fileId,
r2Url: file.r2Url,
mimeType: mobileMediaAsset.mimeType,
filename: mobileMediaAsset.filename,
capturedAt: mobileMediaAsset.capturedAt,
sizeBytes: mobileMediaAsset.sizeBytes,
hash: mobileMediaAsset.hash,
metadata: mobileMediaAsset.metadata,
createdAt: mobileMediaAsset.createdAt,
updatedAt: mobileMediaAsset.updatedAt,
})
.from(mobileMediaAsset) .from(mobileMediaAsset)
.leftJoin(file, eq(mobileMediaAsset.fileId, file.id))
.where(whereClause) .where(whereClause)
.orderBy(orderFn(mobileMediaAsset.createdAt), desc(mobileMediaAsset.id)) .orderBy(orderFn(mobileMediaAsset.createdAt), desc(mobileMediaAsset.id))
.limit(pageSize) .limit(pageSize)
@@ -676,7 +744,7 @@ export class MobileRepository {
fctx: FlowExecCtx, fctx: FlowExecCtx,
mediaAssetId: number, mediaAssetId: number,
ownerUserId: string, ownerUserId: string,
): ResultAsync<boolean, Err> { ): ResultAsync<string, Err> {
const startedAt = Date.now(); const startedAt = Date.now();
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db this.db
@@ -706,27 +774,32 @@ export class MobileRepository {
} }
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db.transaction(async (tx) => { this.db
await tx
.delete(mobileMediaAsset) .delete(mobileMediaAsset)
.where(eq(mobileMediaAsset.id, mediaAssetId)); .where(eq(mobileMediaAsset.id, mediaAssetId))
await tx.delete(file).where(eq(file.id, target.fileId)); .returning({ fileId: mobileMediaAsset.fileId }),
return true;
}),
(error) => (error) =>
mobileErrors.deleteMediaFailed( mobileErrors.deleteMediaFailed(
fctx, fctx,
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
), ),
).andThen((deletedRows) => {
const deleted = deletedRows[0];
if (!deleted) {
return errAsync(
mobileErrors.mediaAssetNotFound(fctx, mediaAssetId),
); );
}).map((deleted) => { }
return okAsync(deleted.fileId);
});
}).map((fileId) => {
logDomainEvent({ logDomainEvent({
event: "mobile.media.delete.succeeded", event: "mobile.media.delete.succeeded",
fctx, fctx,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
meta: { mediaAssetId, ownerUserId, deleted }, meta: { mediaAssetId, ownerUserId, fileId },
}); });
return deleted; return fileId;
}); });
} }
@@ -734,7 +807,7 @@ export class MobileRepository {
fctx: FlowExecCtx, fctx: FlowExecCtx,
deviceId: number, deviceId: number,
ownerUserId: string, ownerUserId: string,
): ResultAsync<{ deleted: boolean; deletedFileCount: number }, Err> { ): ResultAsync<{ fileIds: string[] }, Err> {
const startedAt = Date.now(); const startedAt = Date.now();
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db this.db
@@ -758,35 +831,54 @@ export class MobileRepository {
} }
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db.transaction(async (tx) => { this.db
const mediaFiles = await tx
.select({ fileId: mobileMediaAsset.fileId }) .select({ fileId: mobileMediaAsset.fileId })
.from(mobileMediaAsset) .from(mobileMediaAsset)
.where(eq(mobileMediaAsset.deviceId, deviceId)); .where(eq(mobileMediaAsset.deviceId, deviceId)),
const fileIds = mediaFiles.map((item) => item.fileId);
await tx.delete(mobileDevice).where(eq(mobileDevice.id, deviceId));
if (fileIds.length > 0) {
await tx.delete(file).where(inArray(file.id, fileIds));
}
return { deleted: true, deletedFileCount: fileIds.length };
}),
(error) => (error) =>
mobileErrors.deleteDeviceFailed( mobileErrors.deleteDeviceFailed(
fctx, fctx,
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
), ),
); ).map((rows) => ({
fileIds: [...new Set(rows.map((item) => item.fileId))],
}));
}).map((result) => { }).map((result) => {
logDomainEvent({ logDomainEvent({
event: "mobile.device.delete.succeeded", event: "mobile.device.delete.prepared",
fctx, fctx,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
meta: { deviceId, deletedFileCount: result.deletedFileCount }, meta: { deviceId, deletedFileCount: result.fileIds.length },
}); });
return result; return result;
}); });
} }
finalizeDeleteDevice(
fctx: FlowExecCtx,
deviceId: number,
ownerUserId: string,
): ResultAsync<boolean, Err> {
return ResultAsync.fromPromise(
this.db
.delete(mobileDevice)
.where(
and(
eq(mobileDevice.id, deviceId),
eq(mobileDevice.ownerUserId, ownerUserId),
),
)
.returning({ id: mobileDevice.id }),
(error) =>
mobileErrors.deleteDeviceFailed(
fctx,
error instanceof Error ? error.message : String(error),
),
).andThen((rows) => {
if (!rows[0]) {
return errAsync(mobileErrors.deviceNotFoundById(fctx, deviceId));
}
return okAsync(true);
});
}
} }

View File

@@ -1,5 +1,6 @@
{ {
"name": "@pkg/logic", "name": "@pkg/logic",
"type": "module",
"scripts": { "scripts": {
"auth:schemagen": "pnpm dlx @better-auth/cli generate --config ./domains/auth/config.base.ts --output ../../packages/db/schema/better.auth.schema.ts" "auth:schemagen": "pnpm dlx @better-auth/cli generate --config ./domains/auth/config.base.ts --output ../../packages/db/schema/better.auth.schema.ts"
}, },
@@ -12,6 +13,7 @@
"@pkg/keystore": "workspace:*", "@pkg/keystore": "workspace:*",
"@pkg/logger": "workspace:*", "@pkg/logger": "workspace:*",
"@pkg/result": "workspace:*", "@pkg/result": "workspace:*",
"@pkg/objectstorage": "workspace:*",
"@pkg/settings": "workspace:*", "@pkg/settings": "workspace:*",
"@types/pdfkit": "^0.14.0", "@types/pdfkit": "^0.14.0",
"argon2": "^0.43.0", "argon2": "^0.43.0",

View File

@@ -3,12 +3,15 @@ import {
DeleteObjectCommand, DeleteObjectCommand,
GetObjectCommand, GetObjectCommand,
HeadObjectCommand, HeadObjectCommand,
ListObjectsV2Command,
type ListObjectsV2CommandOutput,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import type { import type {
FileMetadata, FileMetadata,
FileUploadConfig, FileUploadConfig,
PresignedDownloadResult,
PresignedUrlResult, PresignedUrlResult,
UploadOptions, UploadOptions,
UploadResult, UploadResult,
@@ -37,6 +40,7 @@ export class R2StorageClient {
this.s3Client = new S3Client({ this.s3Client = new S3Client({
region: config.region, region: config.region,
endpoint: config.endpoint, endpoint: config.endpoint,
forcePathStyle: true,
credentials: { credentials: {
accessKeyId: config.accessKey, accessKeyId: config.accessKey,
secretAccessKey: config.secretKey, secretAccessKey: config.secretKey,
@@ -263,6 +267,46 @@ export class R2StorageClient {
} }
} }
/**
* Generate presigned URL for private object download/view
*/
async generatePresignedDownloadUrl(
objectKey: string,
expiresIn: number = 3600,
): Promise<Result<PresignedDownloadResult>> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucketName,
Key: objectKey,
});
const downloadUrl = await getSignedUrl(this.s3Client, command, {
expiresIn,
});
logger.info(`Generated presigned download URL for ${objectKey}`);
return {
data: {
downloadUrl,
expiresIn,
},
};
} catch (error) {
logger.error("Failed to generate presigned download URL:", error);
return {
error: getError(
{
code: ERROR_CODES.STORAGE_ERROR,
message: "Failed to generate presigned download URL",
description: "Could not create download URL",
detail: "S3 presigned URL generation failed",
},
error,
),
};
}
}
/** /**
* Get file from R2 * Get file from R2
*/ */
@@ -481,4 +525,52 @@ export class R2StorageClient {
}; };
} }
} }
/**
* List object keys in R2 bucket, optionally filtered by prefix
*/
async listObjectKeys(prefix?: string): Promise<Result<string[]>> {
try {
const keys: string[] = [];
let continuationToken: string | undefined = undefined;
do {
const command: ListObjectsV2Command = new ListObjectsV2Command({
Bucket: this.config.bucketName,
Prefix: prefix,
ContinuationToken: continuationToken,
});
const response: ListObjectsV2CommandOutput =
await this.s3Client.send(command);
const pageKeys =
response.Contents?.map((item) => item.Key).filter(
(key): key is string => Boolean(key),
) || [];
keys.push(...pageKeys);
continuationToken = response.IsTruncated
? response.NextContinuationToken
: undefined;
} while (continuationToken);
logger.info(
`Listed ${keys.length} objects${prefix ? ` with prefix ${prefix}` : ""}`,
);
return { data: keys };
} catch (error) {
logger.error("Failed to list object keys:", error);
return {
error: getError(
{
code: ERROR_CODES.STORAGE_ERROR,
message: "Failed to list objects",
description: "Could not list objects in storage",
detail: "S3 list operation failed",
},
error,
),
};
}
}
} }

View File

@@ -60,6 +60,14 @@ export const presignedUrlResultSchema = v.object({
}); });
export type PresignedUrlResult = v.InferOutput<typeof presignedUrlResultSchema>; export type PresignedUrlResult = v.InferOutput<typeof presignedUrlResultSchema>;
export const presignedDownloadResultSchema = v.object({
downloadUrl: v.string(),
expiresIn: v.pipe(v.number(), v.integer()),
});
export type PresignedDownloadResult = v.InferOutput<
typeof presignedDownloadResultSchema
>;
// File Validation Result Schema // File Validation Result Schema
export const fileValidationResultSchema = v.object({ export const fileValidationResultSchema = v.object({
isValid: v.boolean(), isValid: v.boolean(),
@@ -108,9 +116,15 @@ export const fileProcessingResultSchema = v.object({
metadata: v.optional(v.record(v.string(), v.any())), metadata: v.optional(v.record(v.string(), v.any())),
error: v.optional(v.string()), error: v.optional(v.string()),
}); });
export type FileProcessingResult = v.InferOutput< export type BinaryFileData = Uint8Array<ArrayBufferLike>;
typeof fileProcessingResultSchema export type FileProcessingResult = {
>; processed: boolean;
originalFile?: BinaryFileData;
processedFile?: BinaryFileData;
thumbnail?: BinaryFileData;
metadata?: Record<string, any>;
error?: string;
};
// File Security Result Schema (from utils.ts) // File Security Result Schema (from utils.ts)
export const fileSecurityResultSchema = v.object({ export const fileSecurityResultSchema = v.object({

View File

@@ -1,6 +1,10 @@
import { createHash } from "crypto"; import { createHash } from "crypto";
import type { DocumentProcessingOptions, FileProcessingResult } from "../data"; import type { DocumentProcessingOptions, FileProcessingResult } from "../data";
function asByteArray(input: Buffer | Uint8Array): Uint8Array {
return Uint8Array.from(input);
}
/** /**
* Process documents (PDF, text files, etc.) * Process documents (PDF, text files, etc.)
*/ */
@@ -78,8 +82,8 @@ async function processPDF(
return { return {
processed: true, processed: true,
originalFile: buffer, originalFile: asByteArray(buffer),
processedFile: buffer, // PDFs typically don't need processing processedFile: asByteArray(buffer), // PDFs typically don't need processing
metadata, metadata,
}; };
} }
@@ -108,8 +112,8 @@ async function processTextFile(
return { return {
processed: true, processed: true,
originalFile: buffer, originalFile: asByteArray(buffer),
processedFile: buffer, processedFile: asByteArray(buffer),
metadata, metadata,
}; };
} }
@@ -125,8 +129,8 @@ async function processGenericDocument(
return { return {
processed: true, processed: true,
originalFile: buffer, originalFile: asByteArray(buffer),
processedFile: buffer, processedFile: asByteArray(buffer),
metadata, metadata,
}; };
} }

View File

@@ -145,9 +145,11 @@ export async function processImage(
return { return {
processed: true, processed: true,
originalFile: inputBuffer, originalFile: Uint8Array.from(inputBuffer),
processedFile: processedBuffer, processedFile: Uint8Array.from(processedBuffer),
thumbnail: thumbnailBuffer, thumbnail: thumbnailBuffer
? Uint8Array.from(thumbnailBuffer)
: undefined,
metadata, metadata,
}; };
} catch (error) { } catch (error) {

View File

@@ -49,8 +49,8 @@ export async function processVideo(
return { return {
processed: true, processed: true,
originalFile: inputBuffer, originalFile: Uint8Array.from(inputBuffer),
processedFile: inputBuffer, // Videos are typically not re-encoded during upload processedFile: Uint8Array.from(inputBuffer), // Videos are typically not re-encoded during upload
metadata, metadata,
}; };
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,7 @@
{ {
"name": "@pkg/result", "name": "@pkg/result",
"module": "index.ts",
"type": "module",
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },

39
pnpm-lock.yaml generated
View File

@@ -65,6 +65,9 @@ importers:
'@pkg/settings': '@pkg/settings':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/settings version: link:../../packages/settings
argon2:
specifier: ^0.43.0
version: 0.43.1
better-auth: better-auth:
specifier: ^1.4.20 specifier: ^1.4.20
version: 1.4.20(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(bun-types@1.3.9)(kysely@0.28.11)(postgres@3.4.8))(svelte@5.53.6)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) version: 1.4.20(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(bun-types@1.3.9)(kysely@0.28.11)(postgres@3.4.8))(svelte@5.53.6)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -83,6 +86,9 @@ importers:
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
sharp:
specifier: ^0.34.5
version: 0.34.5
valibot: valibot:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(typescript@5.9.3) version: 1.2.0(typescript@5.9.3)
@@ -195,6 +201,30 @@ importers:
'@hono/node-server': '@hono/node-server':
specifier: ^1.19.9 specifier: ^1.19.9
version: 1.19.9(hono@4.12.3) version: 1.19.9(hono@4.12.3)
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.0
'@opentelemetry/auto-instrumentations-node':
specifier: ^0.70.1
version: 0.70.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))
'@opentelemetry/exporter-logs-otlp-proto':
specifier: ^0.212.0
version: 0.212.0(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-metrics-otlp-proto':
specifier: ^0.212.0
version: 0.212.0(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-trace-otlp-proto':
specifier: ^0.212.0
version: 0.212.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs':
specifier: ^0.212.0
version: 0.212.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics':
specifier: ^2.1.0
version: 2.5.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-node':
specifier: ^0.212.0
version: 0.212.0(@opentelemetry/api@1.9.0)
'@pkg/db': '@pkg/db':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/db version: link:../../packages/db
@@ -204,6 +234,9 @@ importers:
'@pkg/logic': '@pkg/logic':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/logic version: link:../../packages/logic
'@pkg/objectstorage':
specifier: workspace:*
version: link:../../packages/objectstorage
'@pkg/result': '@pkg/result':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/result version: link:../../packages/result
@@ -213,6 +246,9 @@ importers:
hono: hono:
specifier: ^4.11.1 specifier: ^4.11.1
version: 4.12.3 version: 4.12.3
import-in-the-middle:
specifier: ^3.0.0
version: 3.0.0
valibot: valibot:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(typescript@5.9.3) version: 1.2.0(typescript@5.9.3)
@@ -313,6 +349,9 @@ importers:
'@pkg/logger': '@pkg/logger':
specifier: workspace:* specifier: workspace:*
version: link:../logger version: link:../logger
'@pkg/objectstorage':
specifier: workspace:*
version: link:../objectstorage
'@pkg/result': '@pkg/result':
specifier: workspace:* specifier: workspace:*
version: link:../result version: link:../result

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
from pathlib import Path
ENV_ASSIGNMENT_RE = re.compile(
r"^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$"
)
def split_inline_comment(rhs: str) -> tuple[str, str]:
in_single = False
in_double = False
escaped = False
for i, char in enumerate(rhs):
if escaped:
escaped = False
continue
if char == "\\":
escaped = True
continue
if char == "'" and not in_double:
in_single = not in_single
continue
if char == '"' and not in_single:
in_double = not in_double
continue
if char == "#" and not in_single and not in_double:
if i == 0 or rhs[i - 1].isspace():
return rhs[:i], rhs[i:]
return rhs, ""
def transform_line(line: str) -> str:
stripped = line.strip()
if stripped == "" or stripped.startswith("#"):
return line
newline = ""
raw = line
if line.endswith("\r\n"):
newline = "\r\n"
raw = line[:-2]
elif line.endswith("\n"):
newline = "\n"
raw = line[:-1]
match = ENV_ASSIGNMENT_RE.match(raw)
if not match:
return line
prefix, key, delimiter, rhs = match.groups()
value_part, comment_part = split_inline_comment(rhs)
trailing_ws = value_part[len(value_part.rstrip()) :]
placeholder = f"${{{{project.{key}}}}}"
return f"{prefix}{key}{delimiter}{placeholder}{trailing_ws}{comment_part}{newline}"
def generate_example_env(source_path: Path, target_path: Path) -> None:
lines = source_path.read_text(encoding="utf-8").splitlines(keepends=True)
transformed = [transform_line(line) for line in lines]
target_path.write_text("".join(transformed), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate .env.example using ${{project.KEY}} placeholders."
)
parser.add_argument("--source", default=".env", help="Path to source .env file")
parser.add_argument(
"--target", default=".env.example", help="Path to output .env.example file"
)
args = parser.parse_args()
source_path = Path(args.source)
target_path = Path(args.target)
if not source_path.exists():
raise FileNotFoundError(f"Source env file not found: {source_path}")
generate_example_env(source_path, target_path)
if __name__ == "__main__":
main()

157
spec.mobile.md Normal file
View File

@@ -0,0 +1,157 @@
# Mobile Integration Spec (Stage 1)
## Base URL
- Processor endpoints are mounted under `/api/v1/mobile`.
## Headers
- Optional: `x-flow-id` for end-to-end trace correlation.
- Required for ping/sync endpoints: `x-device-id` with a previously registered `externalDeviceId`.
## Endpoints
### Register Device
- Method: `POST`
- Path: `/api/v1/mobile/register`
- Auth: none (trusted private network assumption)
Payload:
```json
{
"externalDeviceId": "android-1234",
"name": "Pixel 8 Pro",
"manufacturer": "Google",
"model": "Pixel 8 Pro",
"androidVersion": "15"
}
```
### Ping Device
- Method: `PUT`
- Path: `/api/v1/mobile/ping`
- Required header: `x-device-id`
Payload:
```json
{
"pingAt": "2026-03-01T10:15:00.000Z"
}
```
### Sync SMS
- Method: `PUT`
- Path: `/api/v1/mobile/sms/sync`
- Required header: `x-device-id`
Payload:
```json
{
"messages": [
{
"externalMessageId": "msg-1",
"sender": "+358401111111",
"recipient": "+358402222222",
"body": "Hello from device",
"sentAt": "2026-03-01T10:10:00.000Z",
"receivedAt": "2026-03-01T10:10:01.000Z",
"rawPayload": {
"threadId": "7"
}
}
]
}
```
### Sync Media Assets
- Method: `PUT`
- Path: `/api/v1/mobile/media/sync`
- Required header: `x-device-id`
- Content-Type: `multipart/form-data`
Upload contract:
- One request uploads one raw media file.
- Multipart field `file` is required (binary).
- Optional multipart metadata fields:
- `externalMediaId`
- `mimeType`
- `filename`
- `capturedAt` (ISO date string)
- `sizeBytes` (number)
- `hash`
- `metadata` (JSON object string)
- Optional metadata headers (alternative to multipart fields):
- `x-media-external-id`
- `x-media-mime-type`
- `x-media-filename`
- `x-media-captured-at`
- `x-media-size-bytes`
- `x-media-hash`
- `x-media-metadata` (JSON object string)
## Response Contract
Success:
```json
{
"data": {},
"error": null
}
```
Failure:
```json
{
"data": null,
"error": {
"flowId": "uuid",
"code": "VALIDATION_ERROR",
"message": "Human message",
"description": "Actionable description",
"detail": "Technical detail"
}
}
```
## Admin Query Contract
- Pagination:
- `page`: 1-based integer
- `pageSize`: integer
- Sorting:
- `sortBy`: operation-specific
- `sortOrder`: `asc` or `desc`
- Paginated response payload:
- `data`: rows
- `total`: full row count
- `page`, `pageSize`, `totalPages`
## Dedup Rules
- Device:
- Upsert on unique `externalDeviceId`.
- SMS:
- Dedup key #1: `(deviceId, externalMessageId)` when provided.
- Dedup key #2 fallback: `(deviceId, dedupHash)` where dedup hash is SHA-256 of `(deviceId + sentAt + sender + body)`.
- Media:
- Raw file is uploaded first and persisted in `file`.
- Then one `mobile_media_asset` row is created referencing uploaded `fileId`.
- Dedup key #1: `(deviceId, externalMediaId)` when provided.
- Dedup key #2 fallback: `(deviceId, hash)` where hash is client-provided or server-computed SHA-256 of file bytes.
## Operator Checklist
1. Register a device.
2. Send ping with `x-device-id` and verify dashboard `lastPingAt` updates.
3. Sync SMS and verify device detail `SMS` tab updates (polling every 5s).
4. Sync media and verify device detail `Media Assets` tab displays rows.