diff --git a/apps/main/src/lib/core/constants.ts b/apps/main/src/lib/core/constants.ts index fd124f7..1638b68 100644 --- a/apps/main/src/lib/core/constants.ts +++ b/apps/main/src/lib/core/constants.ts @@ -1,4 +1,5 @@ import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; +import AppWindow from "@lucide/svelte/icons/app-window"; import { BellRingIcon, Link } from "@lucide/svelte"; import UserCircle from "~icons/lucide/user-circle"; @@ -25,6 +26,11 @@ export const mainNavTree = [ url: "/links", icon: Link, }, + { + title: "Supported Apps", + url: "/supported-apps", + icon: AppWindow, + }, ] as AppSidebarItem[]; export const secondaryNavTree = [ @@ -40,17 +46,7 @@ export const secondaryNavTree = [ }, ] as AppSidebarItem[]; -export const SUPPORTED_APPS = [ - { - title: "Gmail", - packageName: "com.google.android.gm", - }, - { - title: "Outlook", - packageName: "com.microsoft.outlook", - }, - // will add more here when support increases -]; +export const WS_SCRCPY_URL = "https://iotam-ws-scrcpy.snapyra.com"; export const COMPANY_NAME = "SaaS Template"; export const WEBSITE_URL = "https://company.com"; diff --git a/apps/main/src/lib/domains/device/device-details.vm.svelte.ts b/apps/main/src/lib/domains/device/device-details.vm.svelte.ts index f879e6f..91d1d78 100644 --- a/apps/main/src/lib/domains/device/device-details.vm.svelte.ts +++ b/apps/main/src/lib/domains/device/device-details.vm.svelte.ts @@ -1,53 +1,48 @@ +import { WS_SCRCPY_URL } from "$lib/core/constants"; +import type { Device } from "@pkg/logic/domains/device/data"; import { getDeviceByIdSQ } from "./device.remote"; import { toast } from "svelte-sonner"; -type Device = { - id: number; - title: string; - version: string; - status: string; - isActive: boolean; - inUse: boolean; - containerId: string; - host: string; - wsPort: string; +type DeviceForUI = Omit & { createdAt: Date | string; updatedAt: Date | string; }; -function normalizeViewerUrl(host: string, wsPort: string): string | null { +/** Port the scrcpy server listens on inside the Android container. */ +const SCRCPY_SERVER_PORT = 8886; + +/** + * Builds the ws-scrcpy hash-based stream URL for a device. + * + * Example output: + * https://iotam-ws-scrcpy.snapyra.com/#!action=stream&udid=172.17.0.1:5555 + * &player=mse&ws=wss://iotam-ws-scrcpy.snapyra.com/?action=proxy-adb + * &remote=tcp:8886&udid=172.17.0.1:5555 + */ +function buildStreamUrl(host: string, wsPort: string): string | null { const trimmedHost = host.trim(); if (!trimmedHost) return null; - if (trimmedHost.startsWith("http://") || trimmedHost.startsWith("https://")) { - try { - const url = new URL(trimmedHost); - if (!url.port) url.port = wsPort; - return url.toString(); - } catch { - return trimmedHost; - } - } + const udid = `${trimmedHost}:${wsPort}`; + const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); - const hostWithProtocol = `https://${trimmedHost}`; + const wsParam = + `${baseWss}/?action=proxy-adb&remote=tcp:${SCRCPY_SERVER_PORT}&udid=${encodeURIComponent(udid)}`; - try { - const url = new URL(hostWithProtocol); - if (!url.port) url.port = wsPort; - return url.toString(); - } catch { - return null; - } + const hash = + `#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`; + + return `${WS_SCRCPY_URL}/${hash}`; } class DeviceDetailsViewModel { - device = $state(null); + device = $state(null); loading = $state(false); currentId = $state(null); get streamUrl(): string | null { if (!this.device) return null; - return normalizeViewerUrl(this.device.host, this.device.wsPort); + return buildStreamUrl(this.device.host, this.device.wsPort); } async fetchDevice(id: number) { @@ -64,7 +59,7 @@ class DeviceDetailsViewModel { return; } - this.device = result.data as Device; + this.device = result.data as DeviceForUI; } catch (error) { this.device = null; toast.error("Failed to fetch device", { diff --git a/apps/main/src/lib/domains/device/device-form.svelte b/apps/main/src/lib/domains/device/device-form.svelte index 76b60d8..e557deb 100644 --- a/apps/main/src/lib/domains/device/device-form.svelte +++ b/apps/main/src/lib/domains/device/device-form.svelte @@ -3,8 +3,7 @@ import { Input } from "$lib/components/ui/input"; import { Label } from "$lib/components/ui/label"; import { Switch } from "$lib/components/ui/switch"; - - type DeviceStatus = "online" | "offline" | "busy" | "error"; + import type { DeviceStatusValue } from "@pkg/logic/domains/device/data"; let { fieldPrefix = "device-form", @@ -15,7 +14,7 @@ wsPort = $bindable(""), isActive = $bindable(false), inUse = $bindable(false), - status = $bindable("offline" as DeviceStatus), + status = $bindable("offline" as DeviceStatusValue), submitLabel = "Save", submitting = false, showAdvanced = false, @@ -30,7 +29,7 @@ wsPort?: string; isActive?: boolean; inUse?: boolean; - status?: DeviceStatus; + status?: DeviceStatusValue; submitLabel?: string; submitting?: boolean; showAdvanced?: boolean; diff --git a/apps/main/src/lib/domains/device/device.remote.ts b/apps/main/src/lib/domains/device/device.remote.ts index 27ce66a..882d3b0 100644 --- a/apps/main/src/lib/domains/device/device.remote.ts +++ b/apps/main/src/lib/domains/device/device.remote.ts @@ -1,5 +1,9 @@ import { getDeviceController } from "@pkg/logic/domains/device/controller"; -import { createDeviceSchema, updateDeviceSchema } from "@pkg/logic/domains/device/data"; +import { + createDeviceSchema, + deviceStatusSchema, + updateDeviceSchema, +} from "@pkg/logic/domains/device/data"; import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils"; import { command, getRequestEvent, query } from "$app/server"; import * as v from "valibot"; @@ -73,14 +77,14 @@ export const deleteDeviceSC = command( export const setDeviceStatusSC = command( v.object({ id: v.number(), - status: v.picklist(["online", "offline", "busy", "error"]), + status: deviceStatusSchema, }), async (payload) => { const event = getRequestEvent(); const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); if (!fctx.userId) return unauthorized(fctx); - const res = await dc.setStatus(fctx, payload.id, payload.status as any); + const res = await dc.setStatus(fctx, payload.id, payload.status); return res.isOk() ? { data: res.value, error: null } : { data: null, error: res.error }; diff --git a/apps/main/src/lib/domains/device/device.vm.svelte.ts b/apps/main/src/lib/domains/device/device.vm.svelte.ts index f4303cd..b659168 100644 --- a/apps/main/src/lib/domains/device/device.vm.svelte.ts +++ b/apps/main/src/lib/domains/device/device.vm.svelte.ts @@ -1,3 +1,9 @@ +import { + type CreateDevice, + type Device, + type DeviceStatusValue, + type UpdateDevice, +} from "@pkg/logic/domains/device/data"; import { listDevicesSQ, createDeviceSC, @@ -7,34 +13,6 @@ import { } from "./device.remote"; import { toast } from "svelte-sonner"; -type Device = { - id: number; - title: string; - version: string; - status: string; - isActive: boolean; - inUse: boolean; - containerId: string; - host: string; - wsPort: string; - createdAt: Date; - updatedAt: Date; -}; - -type CreateDeviceInput = { - title: string; - version: string; - host: string; - containerId: string; - wsPort: string; - isActive?: boolean; -}; - -type UpdateDeviceInput = CreateDeviceInput & { - status: "online" | "offline" | "busy" | "error"; - inUse: boolean; -}; - class DeviceViewModel { devices = $state([]); loading = $state(false); @@ -71,7 +49,7 @@ class DeviceViewModel { } } - async createDevice(data: CreateDeviceInput): Promise { + async createDevice(data: CreateDevice): Promise { this.creating = true; try { const result = await createDeviceSC(data); @@ -132,7 +110,7 @@ class DeviceViewModel { } } - async updateDevice(id: number, data: UpdateDeviceInput): Promise { + async updateDevice(id: number, data: UpdateDevice): Promise { this.updating = true; this.editingId = id; try { @@ -169,11 +147,11 @@ class DeviceViewModel { } } - async setStatus(id: number, status: string) { + async setStatus(id: number, status: DeviceStatusValue) { try { const result = await setDeviceStatusSC({ id, - status: status as any, + status, }); if (result?.error) { toast.error( diff --git a/apps/main/src/lib/domains/link/link.vm.svelte.ts b/apps/main/src/lib/domains/link/link.vm.svelte.ts index 844cdfb..e0c6403 100644 --- a/apps/main/src/lib/domains/link/link.vm.svelte.ts +++ b/apps/main/src/lib/domains/link/link.vm.svelte.ts @@ -1,3 +1,6 @@ +import type { Device } from "@pkg/logic/domains/device/data"; +import type { CreateLink, LinkWithDevice } from "@pkg/logic/domains/link/data"; +import type { SupportedApp } from "@pkg/logic/domains/supported-app/data"; import { listLinksSQ, createLinkSC, @@ -6,32 +9,17 @@ import { assignDeviceSC, } from "./link.remote"; import { listDevicesSQ } from "../device/device.remote"; +import { listSupportedAppsSQ } from "../supported-app/supported-app.remote"; import { toast } from "svelte-sonner"; -type Link = { - id: number; - token: string; - status: string; - appName: string; - appPackage: string; - linkedDeviceId: number | null; - expiresAt: Date | null; - lastAccessedAt: Date | null; - createdAt: Date; - updatedAt: Date; -}; - -type DeviceOption = { - id: number; - title: string; - host: string; - status: string; - inUse: boolean; -}; +type DeviceOption = Pick; +type SupportedAppOption = Pick; +type CreateLinkInput = Omit; class LinkViewModel { - links = $state([]); + links = $state([]); availableDevices = $state([]); + availableSupportedApps = $state([]); loading = $state(false); creating = $state(false); deletingId = $state(null); @@ -52,7 +40,7 @@ class LinkViewModel { ); return; } - this.links = result.data as Link[]; + this.links = result.data as LinkWithDevice[]; } catch (error) { toast.error("Failed to fetch links", { description: @@ -69,7 +57,7 @@ class LinkViewModel { try { const result = await listDevicesSQ(); if (result?.data) { - this.availableDevices = (result.data as any[]).map((d) => ({ + this.availableDevices = (result.data as Device[]).map((d) => ({ id: d.id, title: d.title, host: d.host, @@ -78,16 +66,22 @@ class LinkViewModel { })); } } catch { - // Non-critical — select will just be empty + // Non-critical - select will just be empty } } - async createLink(data: { - linkedDeviceId: number; - appName: string; - appPackage: string; - expiresAt?: Date | null; - }): Promise { + async fetchSupportedAppsForSelect() { + try { + const result = await listSupportedAppsSQ(); + if (result?.data) { + this.availableSupportedApps = result.data as SupportedAppOption[]; + } + } catch { + // Non-critical - select will just be empty + } + } + + async createLink(data: CreateLinkInput): Promise { this.creating = true; try { const result = await createLinkSC(data); @@ -100,9 +94,7 @@ class LinkViewModel { } toast.success("Link created"); this.showCreateDialog = false; - if (result.data) { - this.links = [...this.links, result.data as Link]; - } + await this.fetchLinks(); return true; } catch (error) { toast.error("Failed to create link", { @@ -197,6 +189,11 @@ class LinkViewModel { const device = this.availableDevices.find((d) => d.id === deviceId); return device ? device.title : `Device #${deviceId}`; } + + getSupportedAppName(supportedAppId: number): string { + const app = this.availableSupportedApps.find((d) => d.id === supportedAppId); + return app ? app.title : `App #${supportedAppId}`; + } } export const linkVM = new LinkViewModel(); diff --git a/apps/main/src/lib/domains/supported-app/supported-app.remote.ts b/apps/main/src/lib/domains/supported-app/supported-app.remote.ts new file mode 100644 index 0000000..68973ed --- /dev/null +++ b/apps/main/src/lib/domains/supported-app/supported-app.remote.ts @@ -0,0 +1,63 @@ +import { getSupportedAppController } from "@pkg/logic/domains/supported-app/controller"; +import { + createSupportedAppSchema, + updateSupportedAppSchema, +} from "@pkg/logic/domains/supported-app/data"; +import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils"; +import { command, getRequestEvent, query } from "$app/server"; +import * as v from "valibot"; + +const sac = getSupportedAppController(); + +export const listSupportedAppsSQ = query(async () => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) return unauthorized(fctx); + + const res = await sac.list(fctx); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; +}); + +export const createSupportedAppSC = command( + createSupportedAppSchema, + async (payload) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) return unauthorized(fctx); + + const res = await sac.create(fctx, payload); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; + }, +); + +export const updateSupportedAppSC = command( + v.object({ id: v.number(), data: updateSupportedAppSchema }), + async (payload) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) return unauthorized(fctx); + + const res = await sac.update(fctx, payload.id, payload.data); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; + }, +); + +export const deleteSupportedAppSC = command( + v.object({ id: v.number() }), + async (payload) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) return unauthorized(fctx); + + const res = await sac.delete(fctx, payload.id); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; + }, +); diff --git a/apps/main/src/lib/domains/supported-app/supported-app.vm.svelte.ts b/apps/main/src/lib/domains/supported-app/supported-app.vm.svelte.ts new file mode 100644 index 0000000..a102efb --- /dev/null +++ b/apps/main/src/lib/domains/supported-app/supported-app.vm.svelte.ts @@ -0,0 +1,152 @@ +import type { + CreateSupportedApp, + SupportedApp, + UpdateSupportedApp, +} from "@pkg/logic/domains/supported-app/data"; +import { + createSupportedAppSC, + deleteSupportedAppSC, + listSupportedAppsSQ, + updateSupportedAppSC, +} from "./supported-app.remote"; +import { toast } from "svelte-sonner"; + +class SupportedAppViewModel { + supportedApps = $state([]); + loading = $state(false); + creating = $state(false); + updating = $state(false); + deletingId = $state(null); + showCreateDialog = $state(false); + + async fetchSupportedApps() { + this.loading = true; + try { + const result = await listSupportedAppsSQ(); + if (result?.error || !result?.data) { + toast.error( + result?.error?.message || "Failed to fetch supported apps", + { + description: + result?.error?.description || "Please try again", + }, + ); + return; + } + this.supportedApps = result.data as SupportedApp[]; + } catch (error) { + toast.error("Failed to fetch supported apps", { + description: + error instanceof Error + ? error.message + : "Please try again", + }); + } finally { + this.loading = false; + } + } + + async createSupportedApp(data: CreateSupportedApp): Promise { + this.creating = true; + try { + const result = await createSupportedAppSC(data); + if (result?.error) { + toast.error( + result.error.message || "Failed to create supported app", + { + description: + result.error.description || "Please try again", + }, + ); + return false; + } + toast.success("Supported app created"); + this.showCreateDialog = false; + if (result.data) { + this.supportedApps = [ + ...this.supportedApps, + result.data as SupportedApp, + ]; + } + return true; + } catch (error) { + toast.error("Failed to create supported app", { + description: + error instanceof Error + ? error.message + : "Please try again", + }); + return false; + } finally { + this.creating = false; + } + } + + async updateSupportedApp( + id: number, + data: UpdateSupportedApp, + ): Promise { + this.updating = true; + try { + const result = await updateSupportedAppSC({ id, data }); + if (result?.error) { + toast.error( + result.error.message || "Failed to update supported app", + { + description: + result.error.description || "Please try again", + }, + ); + return false; + } + toast.success("Supported app updated"); + if (result.data) { + const updated = result.data as SupportedApp; + this.supportedApps = this.supportedApps.map((app) => + app.id === id ? updated : app, + ); + } + return true; + } catch (error) { + toast.error("Failed to update supported app", { + description: + error instanceof Error + ? error.message + : "Please try again", + }); + return false; + } finally { + this.updating = false; + } + } + + async deleteSupportedApp(id: number) { + this.deletingId = id; + try { + const result = await deleteSupportedAppSC({ id }); + if (result?.error) { + toast.error( + result.error.message || "Failed to delete supported app", + { + description: + result.error.description || "Please try again", + }, + ); + return; + } + toast.success("Supported app deleted"); + this.supportedApps = this.supportedApps.filter((app) => app.id !== id); + } catch (error) { + toast.error("Failed to delete supported app", { + description: + error instanceof Error + ? error.message + : "Please try again", + }); + } finally { + this.deletingId = null; + } + } +} + +export const supportedAppVM = new SupportedAppViewModel(); diff --git a/apps/main/src/routes/(main)/links/+page.svelte b/apps/main/src/routes/(main)/links/+page.svelte index a1f4a8e..6543d36 100644 --- a/apps/main/src/routes/(main)/links/+page.svelte +++ b/apps/main/src/routes/(main)/links/+page.svelte @@ -9,7 +9,7 @@ import { Label } from "$lib/components/ui/label"; import * as Table from "$lib/components/ui/table"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; - import { mainNavTree, SUPPORTED_APPS } from "$lib/core/constants"; + import { mainNavTree } from "$lib/core/constants"; import { linkVM } from "$lib/domains/link/link.vm.svelte"; import { breadcrumbs } from "$lib/global.stores"; import LinkIcon from "@lucide/svelte/icons/link"; @@ -24,35 +24,26 @@ breadcrumbs.set([mainNavTree[0], mainNavTree[1]]); let selectedDeviceId = $state(""); - let selectedAppPackage = $state(""); + let selectedSupportedAppId = $state(""); let expiresAt = $state(""); onMount(async () => { await linkVM.fetchLinks(); await linkVM.fetchDevicesForSelect(); + await linkVM.fetchSupportedAppsForSelect(); }); function resetForm() { selectedDeviceId = ""; - selectedAppPackage = ""; + selectedSupportedAppId = ""; expiresAt = ""; } - function getSupportedApp(packageName: string) { - return SUPPORTED_APPS.find((app) => app.packageName === packageName); - } - async function handleCreate(e: Event) { e.preventDefault(); - const selectedApp = getSupportedApp(selectedAppPackage); - if (!selectedApp) { - toast.error("Select a supported app"); - return; - } const success = await linkVM.createLink({ linkedDeviceId: Number(selectedDeviceId), - appName: selectedApp.title, - appPackage: selectedApp.packageName, + supportedAppId: Number(selectedSupportedAppId), expiresAt: expiresAt ? new Date(expiresAt) : null, }); if (success) resetForm(); @@ -121,6 +112,7 @@ onclick={() => { linkVM.showCreateDialog = true; linkVM.fetchDevicesForSelect(); + linkVM.fetchSupportedAppsForSelect(); }} > @@ -176,11 +168,11 @@

App

-

{link.appName}

-

- {link.appPackage} +

+ {link.supportedApp?.title || + linkVM.getSupportedAppName( + link.supportedAppId, + )}

@@ -301,12 +293,10 @@

- {link.appName} -

-

- {link.appPackage} + {link.supportedApp?.title || + linkVM.getSupportedAppName( + link.supportedAppId, + )}

@@ -430,20 +420,19 @@

- Links can only target apps from the current supported app - list. + Supported apps are managed from the dedicated catalogue page.

diff --git a/apps/main/src/routes/(main)/supported-apps/+page.svelte b/apps/main/src/routes/(main)/supported-apps/+page.svelte new file mode 100644 index 0000000..74c1567 --- /dev/null +++ b/apps/main/src/routes/(main)/supported-apps/+page.svelte @@ -0,0 +1,358 @@ + + + + + +
+
+ + Supported Apps + + {supportedAppVM.supportedApps.length} total + +
+
+ + +
+
+
+ + + {#if !supportedAppVM.loading && supportedAppVM.supportedApps.length === 0} +
+ No supported apps configured yet. +
+ {:else} +
+ {#each supportedAppVM.supportedApps as app (app.id)} +
+
+

{app.title}

+

+ {app.packageName} +

+
+
+ Updated {formatDate(app.updatedAt)} +
+
+ + + + + + + + + Delete supported app? + + + This removes "{app.title}" from + the supported app catalogue. + + + + + Cancel + + + supportedAppVM.deleteSupportedApp( + app.id, + )} + > + Delete + + + + +
+
+ {/each} +
+ + + {/if} +
+
+
+ + + + + Add Supported App + + Register an app that links are allowed to lease. + + +
+
+ + +
+
+ + +
+ + + + +
+
+
+ + + + + Edit Supported App + + Update the supported app record used by the links flow. + + +
+
+ + +
+
+ + +
+ + + + +
+
+
diff --git a/memory.log.md b/memory.log.md index e6df3c1..213ab28 100644 --- a/memory.log.md +++ b/memory.log.md @@ -90,3 +90,23 @@ Update rule: - Cleaned up unused `Smartphone` import from constants.ts - Updated detail page breadcrumbs to go Dashboard > Device instead of Dashboard > Devices > Device - Updated `goto` calls to navigate to `/dashboard/:id` instead of `/devices/:id` + +### 12 — Supported Apps Catalogue + +- Added a normalized `supported_app` schema and matching logic domain for supported-app CRUD +- Added an admin `/supported-apps` page and sidebar entry for managing the app catalogue +- Refactored links to reference `supportedAppId` and load supported app labels from the catalogue instead of freeform app fields/constants + +### 13 — Fixed ws-scrcpy Stream URL + +- Added `WS_SCRCPY_URL` constant in `constants.ts` for the static public ws-scrcpy domain +- Replaced broken `normalizeViewerUrl` (which wrongly jammed ADB port into the public domain) with `buildStreamUrl` in `device-details.vm.svelte.ts` +- New builder constructs the correct hash-based ws-scrcpy stream URL: `#!action=stream&udid={host}:{wsPort}&player=mse&ws=wss://.../?action=proxy-adb&remote=tcp:8886&udid={host}:{wsPort}` +- Device `host` and `wsPort` are now correctly treated as internal ADB address (e.g. `172.17.0.1:5555`), not the public domain +- Scrcpy server port (8886) hardcoded as constant since it's static per the ws-scrcpy architecture + +### 14 — Frontend Domain Type Source-of-Truth Refactor + +- Refactored `apps/main/src/lib/domains/{device,link,supported-app}` to remove local duplicate data type declarations and import canonical types from `@pkg/logic/domains/*/data` +- Updated device status validation in `device.remote.ts` to reuse `deviceStatusSchema` from logic instead of a duplicated local picklist +- Kept only derived UI helper types (`Pick`/`Omit`) where needed for presentation and transport-shape compatibility diff --git a/packages/db/migrations/0003_workable_runaways.sql b/packages/db/migrations/0003_workable_runaways.sql new file mode 100644 index 0000000..1f82c8c --- /dev/null +++ b/packages/db/migrations/0003_workable_runaways.sql @@ -0,0 +1,13 @@ +CREATE TABLE "supported_app" ( + "id" serial PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "package_name" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + CONSTRAINT "supported_app_package_name_unique" UNIQUE("package_name") +); +--> statement-breakpoint +ALTER TABLE "link" ADD COLUMN "supported_app_id" integer NOT NULL;--> statement-breakpoint +ALTER TABLE "link" ADD CONSTRAINT "link_supported_app_id_supported_app_id_fk" FOREIGN KEY ("supported_app_id") REFERENCES "public"."supported_app"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "link" DROP COLUMN "app_name";--> statement-breakpoint +ALTER TABLE "link" DROP COLUMN "app_package"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0003_snapshot.json b/packages/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..aa417ef --- /dev/null +++ b/packages/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,997 @@ +{ + "id": "f9535ed5-4fb8-4f58-b741-712a5c7356a1", + "prevId": "a633b0b6-32a7-4f7f-8b17-4264fe54ca57", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.twofa_sessions": { + "name": "twofa_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_used": { + "name": "code_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": { + "twofa_sessions_user_id_user_id_fk": { + "name": "twofa_sessions_user_id_user_id_fk", + "tableFrom": "twofa_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "twofa_sessions_verification_token_unique": { + "name": "twofa_sessions_verification_token_unique", + "nullsNotDistinct": false, + "columns": [ + "verification_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "onboarding_done": { + "name": "onboarding_done", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last2_fa_verified_at": { + "name": "last2_fa_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "in_use": { + "name": "in_use", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ws_port": { + "name": "ws_port", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "type": { + "name": "type", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "action_url": { + "name": "action_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "action_data": { + "name": "action_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "linked_device_id": { + "name": "linked_device_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supported_app_id": { + "name": "supported_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "link_linked_device_id_device_id_fk": { + "name": "link_linked_device_id_device_id_fk", + "tableFrom": "link", + "tableTo": "device", + "columnsFrom": [ + "linked_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "link_supported_app_id_supported_app_id_fk": { + "name": "link_supported_app_id_supported_app_id_fk", + "tableFrom": "link", + "tableTo": "supported_app", + "columnsFrom": [ + "supported_app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "link_token_unique": { + "name": "link_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supported_app": { + "name": "supported_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "supported_app_package_name_unique": { + "name": "supported_app_package_name_unique", + "nullsNotDistinct": false, + "columns": [ + "package_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "task_user_id_user_id_fk": { + "name": "task_user_id_user_id_fk", + "tableFrom": "task", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index e35293b..db1aedb 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1774703478082, "tag": "0002_remarkable_charles_xavier", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1774705734646, + "tag": "0003_workable_runaways", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 2ddcd31..29c24e3 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -3,4 +3,5 @@ export * from "./better.auth.schema"; export * from "./device.schema"; export * from "./general.schema"; export * from "./link.schema"; +export * from "./supported-app.schema"; export * from "./task.schema"; diff --git a/packages/db/schema/link.schema.ts b/packages/db/schema/link.schema.ts index 7299374..4f5f0d4 100644 --- a/packages/db/schema/link.schema.ts +++ b/packages/db/schema/link.schema.ts @@ -6,6 +6,7 @@ import { timestamp, varchar, } from "drizzle-orm/pg-core"; +import { supportedApp } from "./supported-app.schema"; import { device } from "./device.schema"; import { relations } from "drizzle-orm"; @@ -14,12 +15,14 @@ export const link = pgTable("link", { token: text("token").notNull().unique(), status: varchar("status", { length: 16 }).notNull().default("active"), // "active" | "inactive" | "expired" | "revoked" - appName: text("app_name").notNull(), - appPackage: text("app_package").notNull(), - linkedDeviceId: integer("linked_device_id").references(() => device.id, { onDelete: "set null", }), + supportedAppId: integer("supported_app_id") + .notNull() + .references(() => supportedApp.id, { + onDelete: "restrict", + }), expiresAt: timestamp("expires_at"), lastAccessedAt: timestamp("last_accessed_at"), @@ -32,4 +35,8 @@ export const linkRelations = relations(link, ({ one }) => ({ fields: [link.linkedDeviceId], references: [device.id], }), + supportedApp: one(supportedApp, { + fields: [link.supportedAppId], + references: [supportedApp.id], + }), })); diff --git a/packages/db/schema/supported-app.schema.ts b/packages/db/schema/supported-app.schema.ts new file mode 100644 index 0000000..9498bc6 --- /dev/null +++ b/packages/db/schema/supported-app.schema.ts @@ -0,0 +1,9 @@ +import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const supportedApp = pgTable("supported_app", { + id: serial("id").primaryKey(), + title: text("title").notNull(), + packageName: text("package_name").notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); diff --git a/packages/logic/domains/link/controller.ts b/packages/logic/domains/link/controller.ts index be91702..984508d 100644 --- a/packages/logic/domains/link/controller.ts +++ b/packages/logic/domains/link/controller.ts @@ -3,18 +3,24 @@ import { nanoid } from "nanoid"; import { db } from "@pkg/db"; import { type Err } from "@pkg/result"; import { FlowExecCtx } from "@core/flow.execution.context"; -import { CreateLink, Link, LinkStatus, LinkWithDevice, UpdateLink } from "./data"; +import { + CreateLink, + Link, + LinkStatus, + LinkWithDevice, + UpdateLink, +} from "./data"; import { LinkRepository } from "./repository"; import { linkErrors } from "./errors"; export class LinkController { constructor(private repo: LinkRepository) {} - list(fctx: FlowExecCtx): ResultAsync { + list(fctx: FlowExecCtx): ResultAsync { return this.repo.list(fctx); } - getById(fctx: FlowExecCtx, id: number): ResultAsync { + getById(fctx: FlowExecCtx, id: number): ResultAsync { return this.repo.getById(fctx, id); } diff --git a/packages/logic/domains/link/data.ts b/packages/logic/domains/link/data.ts index d01136a..39a1eaf 100644 --- a/packages/logic/domains/link/data.ts +++ b/packages/logic/domains/link/data.ts @@ -1,5 +1,6 @@ import * as v from "valibot"; import { deviceSchema } from "@domains/device/data"; +import { supportedAppSchema } from "@domains/supported-app/data"; export enum LinkStatus { ACTIVE = "active", @@ -15,9 +16,8 @@ export const linkSchema = v.object({ id: v.number(), token: v.string(), status: linkStatusSchema, - appName: v.string(), - appPackage: v.string(), linkedDeviceId: v.nullable(v.number()), + supportedAppId: v.number(), expiresAt: v.nullable(v.date()), lastAccessedAt: v.nullable(v.date()), createdAt: v.date(), @@ -28,14 +28,14 @@ export type Link = v.InferOutput; export const linkWithDeviceSchema = v.object({ ...linkSchema.entries, device: v.nullable(deviceSchema), + supportedApp: v.nullable(supportedAppSchema), }); export type LinkWithDevice = v.InferOutput; export const createLinkSchema = v.object({ token: v.pipe(v.string(), v.minLength(1)), - appName: v.pipe(v.string(), v.minLength(1)), - appPackage: v.pipe(v.string(), v.minLength(1)), linkedDeviceId: v.number(), + supportedAppId: v.number(), expiresAt: v.optional(v.nullable(v.date())), }); export type CreateLink = v.InferOutput; @@ -43,9 +43,8 @@ export type CreateLink = v.InferOutput; export const updateLinkSchema = v.partial( v.object({ status: linkStatusSchema, - appName: v.string(), - appPackage: v.string(), linkedDeviceId: v.nullable(v.number()), + supportedAppId: v.number(), expiresAt: v.nullable(v.date()), lastAccessedAt: v.nullable(v.date()), }), diff --git a/packages/logic/domains/link/repository.ts b/packages/logic/domains/link/repository.ts index bb88b69..bfc79bd 100644 --- a/packages/logic/domains/link/repository.ts +++ b/packages/logic/domains/link/repository.ts @@ -1,6 +1,6 @@ import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { FlowExecCtx } from "@core/flow.execution.context"; -import { Database, asc, eq } from "@pkg/db"; +import { Database, eq } from "@pkg/db"; import { link } from "@pkg/db/schema"; import { type Err } from "@pkg/result"; import { logger } from "@pkg/logger"; @@ -11,29 +11,41 @@ import { linkErrors } from "./errors"; export class LinkRepository { constructor(private db: Database) {} - list(fctx: FlowExecCtx): ResultAsync { + list(fctx: FlowExecCtx): ResultAsync { return traceResultAsync({ name: "link.list", fctx, fn: () => ResultAsync.fromPromise( - this.db.select().from(link).orderBy(asc(link.createdAt)), + this.db.query.link.findMany({ + orderBy: (link, { asc }) => [asc(link.createdAt)], + with: { + device: true, + supportedApp: true, + }, + }), (e) => linkErrors.listFailed( fctx, e instanceof Error ? e.message : String(e), ), - ).map((rows) => rows as Link[]), + ).map((rows) => rows as LinkWithDevice[]), }); } - getById(fctx: FlowExecCtx, id: number): ResultAsync { + getById(fctx: FlowExecCtx, id: number): ResultAsync { return traceResultAsync({ name: "link.getById", fctx, fn: () => ResultAsync.fromPromise( - this.db.query.link.findFirst({ where: eq(link.id, id) }), + this.db.query.link.findFirst({ + where: eq(link.id, id), + with: { + device: true, + supportedApp: true, + }, + }), (e) => linkErrors.dbError( fctx, @@ -41,7 +53,7 @@ export class LinkRepository { ), ).andThen((row) => { if (!row) return errAsync(linkErrors.linkNotFound(fctx, id)); - return okAsync(row as Link); + return okAsync(row as LinkWithDevice); }), }); } @@ -54,7 +66,7 @@ export class LinkRepository { ResultAsync.fromPromise( this.db.query.link.findFirst({ where: eq(link.token, token), - with: { device: true }, + with: { device: true, supportedApp: true }, }), (e) => linkErrors.dbError( @@ -81,9 +93,8 @@ export class LinkRepository { .values({ token: data.token, status: "active", - appName: data.appName, - appPackage: data.appPackage, linkedDeviceId: data.linkedDeviceId ?? null, + supportedAppId: data.supportedAppId, expiresAt: data.expiresAt ?? null, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/logic/domains/supported-app/controller.ts b/packages/logic/domains/supported-app/controller.ts new file mode 100644 index 0000000..c99b93e --- /dev/null +++ b/packages/logic/domains/supported-app/controller.ts @@ -0,0 +1,45 @@ +import { db } from "@pkg/db"; +import { type Err } from "@pkg/result"; +import { FlowExecCtx } from "@core/flow.execution.context"; +import { ResultAsync } from "neverthrow"; +import { + CreateSupportedApp, + SupportedApp, + UpdateSupportedApp, +} from "./data"; +import { SupportedAppRepository } from "./repository"; + +export class SupportedAppController { + constructor(private repo: SupportedAppRepository) {} + + list(fctx: FlowExecCtx): ResultAsync { + return this.repo.list(fctx); + } + + getById(fctx: FlowExecCtx, id: number): ResultAsync { + return this.repo.getById(fctx, id); + } + + create( + fctx: FlowExecCtx, + data: CreateSupportedApp, + ): ResultAsync { + return this.repo.create(fctx, data); + } + + update( + fctx: FlowExecCtx, + id: number, + data: UpdateSupportedApp, + ): ResultAsync { + return this.repo.update(fctx, id, data); + } + + delete(fctx: FlowExecCtx, id: number): ResultAsync { + return this.repo.delete(fctx, id); + } +} + +export function getSupportedAppController(): SupportedAppController { + return new SupportedAppController(new SupportedAppRepository(db)); +} diff --git a/packages/logic/domains/supported-app/data.ts b/packages/logic/domains/supported-app/data.ts new file mode 100644 index 0000000..06b5e02 --- /dev/null +++ b/packages/logic/domains/supported-app/data.ts @@ -0,0 +1,28 @@ +import * as v from "valibot"; + +export const supportedAppSchema = v.object({ + id: v.number(), + title: v.string(), + packageName: v.string(), + createdAt: v.date(), + updatedAt: v.date(), +}); +export type SupportedApp = v.InferOutput; + +export const createSupportedAppSchema = v.object({ + title: v.pipe(v.string(), v.minLength(1)), + packageName: v.pipe(v.string(), v.minLength(1)), +}); +export type CreateSupportedApp = v.InferOutput< + typeof createSupportedAppSchema +>; + +export const updateSupportedAppSchema = v.partial( + v.object({ + title: v.string(), + packageName: v.string(), + }), +); +export type UpdateSupportedApp = v.InferOutput< + typeof updateSupportedAppSchema +>; diff --git a/packages/logic/domains/supported-app/errors.ts b/packages/logic/domains/supported-app/errors.ts new file mode 100644 index 0000000..0904be1 --- /dev/null +++ b/packages/logic/domains/supported-app/errors.ts @@ -0,0 +1,59 @@ +import { FlowExecCtx } from "@/core/flow.execution.context"; +import { ERROR_CODES, type Err } from "@pkg/result"; +import { getError } from "@pkg/logger"; + +export const supportedAppErrors = { + dbError: (fctx: FlowExecCtx, detail: string): Err => + getError({ + flowId: fctx.flowId, + code: ERROR_CODES.DATABASE_ERROR, + message: "Database operation failed", + description: "Please try again later", + detail, + }), + + supportedAppNotFound: (fctx: FlowExecCtx, id: number): Err => + getError({ + flowId: fctx.flowId, + code: ERROR_CODES.NOT_FOUND, + message: "Supported app not found", + description: "The requested supported app does not exist", + detail: `No supported app found with ID: ${id}`, + }), + + listFailed: (fctx: FlowExecCtx, detail: string): Err => + getError({ + flowId: fctx.flowId, + code: ERROR_CODES.DATABASE_ERROR, + message: "Failed to list supported apps", + description: "Try again later", + detail, + }), + + createFailed: (fctx: FlowExecCtx, detail: string): Err => + getError({ + flowId: fctx.flowId, + code: ERROR_CODES.DATABASE_ERROR, + message: "Failed to create supported app", + description: "Try again later", + detail, + }), + + updateFailed: (fctx: FlowExecCtx, detail: string): Err => + getError({ + flowId: fctx.flowId, + code: ERROR_CODES.DATABASE_ERROR, + message: "Failed to update supported app", + description: "Try again later", + detail, + }), + + deleteFailed: (fctx: FlowExecCtx, detail: string): Err => + getError({ + flowId: fctx.flowId, + code: ERROR_CODES.DATABASE_ERROR, + message: "Failed to delete supported app", + description: "Try again later", + detail, + }), +}; diff --git a/packages/logic/domains/supported-app/repository.ts b/packages/logic/domains/supported-app/repository.ts new file mode 100644 index 0000000..e5e8d2b --- /dev/null +++ b/packages/logic/domains/supported-app/repository.ts @@ -0,0 +1,149 @@ +import { ResultAsync, errAsync, okAsync } from "neverthrow"; +import { FlowExecCtx } from "@core/flow.execution.context"; +import { Database, asc, eq } from "@pkg/db"; +import { supportedApp } from "@pkg/db/schema"; +import { type Err } from "@pkg/result"; +import { logger } from "@pkg/logger"; +import { traceResultAsync } from "@core/observability"; +import { + CreateSupportedApp, + SupportedApp, + UpdateSupportedApp, +} from "./data"; +import { supportedAppErrors } from "./errors"; + +export class SupportedAppRepository { + constructor(private db: Database) {} + + list(fctx: FlowExecCtx): ResultAsync { + return traceResultAsync({ + name: "supportedApp.list", + fctx, + fn: () => + ResultAsync.fromPromise( + this.db + .select() + .from(supportedApp) + .orderBy(asc(supportedApp.createdAt)), + (e) => + supportedAppErrors.listFailed( + fctx, + e instanceof Error ? e.message : String(e), + ), + ).map((rows) => rows as SupportedApp[]), + }); + } + + getById(fctx: FlowExecCtx, id: number): ResultAsync { + return traceResultAsync({ + name: "supportedApp.getById", + fctx, + fn: () => + ResultAsync.fromPromise( + this.db.query.supportedApp.findFirst({ + where: eq(supportedApp.id, id), + }), + (e) => + supportedAppErrors.dbError( + fctx, + e instanceof Error ? e.message : String(e), + ), + ).andThen((row) => { + if (!row) { + return errAsync( + supportedAppErrors.supportedAppNotFound(fctx, id), + ); + } + return okAsync(row as SupportedApp); + }), + }); + } + + create( + fctx: FlowExecCtx, + data: CreateSupportedApp, + ): ResultAsync { + logger.info("Creating supported app", { ...fctx, title: data.title }); + + return traceResultAsync({ + name: "supportedApp.create", + fctx, + fn: () => + ResultAsync.fromPromise( + this.db + .insert(supportedApp) + .values({ + title: data.title, + packageName: data.packageName, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + .execute(), + (e) => + supportedAppErrors.createFailed( + fctx, + e instanceof Error ? e.message : String(e), + ), + ).map((rows) => rows[0] as SupportedApp), + }); + } + + update( + fctx: FlowExecCtx, + id: number, + updates: UpdateSupportedApp, + ): ResultAsync { + return traceResultAsync({ + name: "supportedApp.update", + fctx, + fn: () => + this.getById(fctx, id).andThen(() => + ResultAsync.fromPromise( + this.db + .update(supportedApp) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(supportedApp.id, id)) + .returning() + .execute(), + (e) => + supportedAppErrors.updateFailed( + fctx, + e instanceof Error ? e.message : String(e), + ), + ).andThen((rows) => { + if (!rows[0]) { + return errAsync( + supportedAppErrors.supportedAppNotFound( + fctx, + id, + ), + ); + } + return okAsync(rows[0] as SupportedApp); + }), + ), + }); + } + + delete(fctx: FlowExecCtx, id: number): ResultAsync { + return traceResultAsync({ + name: "supportedApp.delete", + fctx, + fn: () => + this.getById(fctx, id).andThen(() => + ResultAsync.fromPromise( + this.db + .delete(supportedApp) + .where(eq(supportedApp.id, id)) + .execute(), + (e) => + supportedAppErrors.deleteFailed( + fctx, + e instanceof Error ? e.message : String(e), + ), + ).map(() => true), + ), + }); + } +} diff --git a/packages/settings/index.ts b/packages/settings/index.ts index 3929a5d..cf31785 100644 --- a/packages/settings/index.ts +++ b/packages/settings/index.ts @@ -19,7 +19,7 @@ export const settingsSchema = v.object({ debugKey: v.string(), orchestratorApiUrl: v.string(), - wsScrcpySvcUrl: v.string(), + publicWsScrcpySvcUrl: v.string(), betterAuthUrl: v.string(), betterAuthSecret: v.string(), @@ -87,7 +87,7 @@ function loadSettings(): Settings { "ORCHESTRATOR_API_URL", "http://localhost:3000", ), - wsScrcpySvcUrl: getEnv("WS_SCRCPY_SVC_URL"), + publicWsScrcpySvcUrl: getEnv("PUBLIC_WS_SCRCPY_SVC_URL"), betterAuthUrl: getEnv("BETTER_AUTH_URL"), betterAuthSecret: getEnv("BETTER_AUTH_SECRET"), diff --git a/ws-scrcpy b/ws-scrcpy new file mode 160000 index 0000000..49d2623 --- /dev/null +++ b/ws-scrcpy @@ -0,0 +1 @@ +Subproject commit 49d26231840cafcde77a3e778b804d8af498a5ac