supported apps domain + some refactor of data types redundancy
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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<Device, "createdAt" | "updatedAt"> & {
|
||||
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<Device | null>(null);
|
||||
device = $state<DeviceForUI | null>(null);
|
||||
loading = $state(false);
|
||||
currentId = $state<number | null>(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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<Device[]>([]);
|
||||
loading = $state(false);
|
||||
@@ -71,7 +49,7 @@ class DeviceViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
async createDevice(data: CreateDeviceInput): Promise<boolean> {
|
||||
async createDevice(data: CreateDevice): Promise<boolean> {
|
||||
this.creating = true;
|
||||
try {
|
||||
const result = await createDeviceSC(data);
|
||||
@@ -132,7 +110,7 @@ class DeviceViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
async updateDevice(id: number, data: UpdateDeviceInput): Promise<boolean> {
|
||||
async updateDevice(id: number, data: UpdateDevice): Promise<boolean> {
|
||||
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(
|
||||
|
||||
@@ -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<Device, "id" | "title" | "host" | "status" | "inUse">;
|
||||
type SupportedAppOption = Pick<SupportedApp, "id" | "title" | "packageName">;
|
||||
type CreateLinkInput = Omit<CreateLink, "token">;
|
||||
|
||||
class LinkViewModel {
|
||||
links = $state<Link[]>([]);
|
||||
links = $state<LinkWithDevice[]>([]);
|
||||
availableDevices = $state<DeviceOption[]>([]);
|
||||
availableSupportedApps = $state<SupportedAppOption[]>([]);
|
||||
loading = $state(false);
|
||||
creating = $state(false);
|
||||
deletingId = $state<number | null>(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<boolean> {
|
||||
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<boolean> {
|
||||
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();
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
@@ -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<SupportedApp[]>([]);
|
||||
loading = $state(false);
|
||||
creating = $state(false);
|
||||
updating = $state(false);
|
||||
deletingId = $state<number | null>(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<boolean> {
|
||||
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<boolean> {
|
||||
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();
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
|
||||
@@ -176,11 +168,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">App</p>
|
||||
<p>{link.appName}</p>
|
||||
<p
|
||||
class="text-muted-foreground break-all font-mono text-[11px]"
|
||||
>
|
||||
{link.appPackage}
|
||||
<p>
|
||||
{link.supportedApp?.title ||
|
||||
linkVM.getSupportedAppName(
|
||||
link.supportedAppId,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -301,12 +293,10 @@
|
||||
<Table.Cell>
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
{link.appName}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground font-mono text-[11px]"
|
||||
>
|
||||
{link.appPackage}
|
||||
{link.supportedApp?.title ||
|
||||
linkVM.getSupportedAppName(
|
||||
link.supportedAppId,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
@@ -430,20 +420,19 @@
|
||||
<Label for="supportedApp">Supported App</Label>
|
||||
<select
|
||||
id="supportedApp"
|
||||
bind:value={selectedAppPackage}
|
||||
bind:value={selectedSupportedAppId}
|
||||
required
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<option value="" disabled>Select a supported app</option>
|
||||
{#each SUPPORTED_APPS as app}
|
||||
<option value={app.packageName}>
|
||||
{app.title} — {app.packageName}
|
||||
{#each linkVM.availableSupportedApps as app}
|
||||
<option value={String(app.id)}>
|
||||
{app.title}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Links can only target apps from the current supported app
|
||||
list.
|
||||
Supported apps are managed from the dedicated catalogue page.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
|
||||
358
apps/main/src/routes/(main)/supported-apps/+page.svelte
Normal file
358
apps/main/src/routes/(main)/supported-apps/+page.svelte
Normal file
@@ -0,0 +1,358 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
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 } from "$lib/core/constants";
|
||||
import { supportedAppVM } from "$lib/domains/supported-app/supported-app.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import AppWindow from "@lucide/svelte/icons/app-window";
|
||||
import Pencil from "@lucide/svelte/icons/pencil";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import Trash2 from "@lucide/svelte/icons/trash-2";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0], mainNavTree[2]]);
|
||||
|
||||
let title = $state("");
|
||||
let packageName = $state("");
|
||||
let editId = $state<number | null>(null);
|
||||
let editTitle = $state("");
|
||||
let editPackageName = $state("");
|
||||
let showEditDialog = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await supportedAppVM.fetchSupportedApps();
|
||||
});
|
||||
|
||||
function resetCreateForm() {
|
||||
title = "";
|
||||
packageName = "";
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editId = null;
|
||||
editTitle = "";
|
||||
editPackageName = "";
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await supportedAppVM.createSupportedApp({
|
||||
title,
|
||||
packageName,
|
||||
});
|
||||
if (success) resetCreateForm();
|
||||
}
|
||||
|
||||
function openEditDialog(app: (typeof supportedAppVM.supportedApps)[number]) {
|
||||
editId = app.id;
|
||||
editTitle = app.title;
|
||||
editPackageName = app.packageName;
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
async function handleUpdate(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!editId) return;
|
||||
const success = await supportedAppVM.updateSupportedApp(editId, {
|
||||
title: editTitle,
|
||||
packageName: editPackageName,
|
||||
});
|
||||
if (success) {
|
||||
showEditDialog = false;
|
||||
resetEditForm();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: Date | string): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showEditDialog) return;
|
||||
resetEditForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={AppWindow} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Supported Apps</Card.Title>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{supportedAppVM.supportedApps.length} total
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void supportedAppVM.fetchSupportedApps()}
|
||||
disabled={supportedAppVM.loading}
|
||||
>
|
||||
<Icon
|
||||
icon={RefreshCw}
|
||||
cls={`mr-2 h-4 w-4 ${supportedAppVM.loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (supportedAppVM.showCreateDialog = true)}
|
||||
>
|
||||
<Icon icon={Plus} cls="mr-2 h-4 w-4" />
|
||||
Add Supported App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
{#if !supportedAppVM.loading && supportedAppVM.supportedApps.length === 0}
|
||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||
No supported apps configured yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each supportedAppVM.supportedApps as app (app.id)}
|
||||
<div class="rounded-lg border bg-background p-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{app.title}</p>
|
||||
<p
|
||||
class="text-muted-foreground break-all font-mono text-xs"
|
||||
>
|
||||
{app.packageName}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-3 text-xs">
|
||||
Updated {formatDate(app.updatedAt)}
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openEditDialog(app)}
|
||||
>
|
||||
<Icon icon={Pencil} cls="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={supportedAppVM.deletingId ===
|
||||
app.id}
|
||||
>
|
||||
<Icon icon={Trash2} cls="h-4 w-4" />
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete supported app?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This removes "{app.title}" from
|
||||
the supported app catalogue.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
supportedAppVM.deleteSupportedApp(
|
||||
app.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Package Name</Table.Head>
|
||||
<Table.Head>Updated</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each supportedAppVM.supportedApps as app (app.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
{app.title}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{app.packageName}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
{formatDate(app.updatedAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
openEditDialog(app)}
|
||||
>
|
||||
<Icon
|
||||
icon={Pencil}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={supportedAppVM.deletingId ===
|
||||
app.id}
|
||||
>
|
||||
<Icon
|
||||
icon={Trash2}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Delete
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete supported
|
||||
app?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This removes
|
||||
"{app.title}" from
|
||||
the supported app
|
||||
catalogue.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
supportedAppVM.deleteSupportedApp(
|
||||
app.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</MaxWidthWrapper>
|
||||
|
||||
<Dialog.Root bind:open={supportedAppVM.showCreateDialog}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Add Supported App</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Register an app that links are allowed to lease.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleCreate} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input id="title" bind:value={title} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="packageName">Package Name</Label>
|
||||
<Input
|
||||
id="packageName"
|
||||
bind:value={packageName}
|
||||
placeholder="com.example.app"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={() => (supportedAppVM.showCreateDialog = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={supportedAppVM.creating}>
|
||||
{supportedAppVM.creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit Supported App</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Update the supported app record used by the links flow.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleUpdate} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-title">Title</Label>
|
||||
<Input id="edit-title" bind:value={editTitle} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-packageName">Package Name</Label>
|
||||
<Input
|
||||
id="edit-packageName"
|
||||
bind:value={editPackageName}
|
||||
placeholder="com.example.app"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showEditDialog = false;
|
||||
resetEditForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={supportedAppVM.updating}>
|
||||
{supportedAppVM.updating ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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
|
||||
|
||||
13
packages/db/migrations/0003_workable_runaways.sql
Normal file
13
packages/db/migrations/0003_workable_runaways.sql
Normal file
@@ -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";
|
||||
997
packages/db/migrations/meta/0003_snapshot.json
Normal file
997
packages/db/migrations/meta/0003_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
|
||||
9
packages/db/schema/supported-app.schema.ts
Normal file
9
packages/db/schema/supported-app.schema.ts
Normal file
@@ -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(),
|
||||
});
|
||||
@@ -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<Link[], Err> {
|
||||
list(fctx: FlowExecCtx): ResultAsync<LinkWithDevice[], Err> {
|
||||
return this.repo.list(fctx);
|
||||
}
|
||||
|
||||
getById(fctx: FlowExecCtx, id: number): ResultAsync<Link, Err> {
|
||||
getById(fctx: FlowExecCtx, id: number): ResultAsync<LinkWithDevice, Err> {
|
||||
return this.repo.getById(fctx, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof linkSchema>;
|
||||
export const linkWithDeviceSchema = v.object({
|
||||
...linkSchema.entries,
|
||||
device: v.nullable(deviceSchema),
|
||||
supportedApp: v.nullable(supportedAppSchema),
|
||||
});
|
||||
export type LinkWithDevice = v.InferOutput<typeof linkWithDeviceSchema>;
|
||||
|
||||
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<typeof createLinkSchema>;
|
||||
@@ -43,9 +43,8 @@ export type CreateLink = v.InferOutput<typeof createLinkSchema>;
|
||||
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()),
|
||||
}),
|
||||
|
||||
@@ -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<Link[], Err> {
|
||||
list(fctx: FlowExecCtx): ResultAsync<LinkWithDevice[], Err> {
|
||||
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<Link, Err> {
|
||||
getById(fctx: FlowExecCtx, id: number): ResultAsync<LinkWithDevice, Err> {
|
||||
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(),
|
||||
|
||||
45
packages/logic/domains/supported-app/controller.ts
Normal file
45
packages/logic/domains/supported-app/controller.ts
Normal file
@@ -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<SupportedApp[], Err> {
|
||||
return this.repo.list(fctx);
|
||||
}
|
||||
|
||||
getById(fctx: FlowExecCtx, id: number): ResultAsync<SupportedApp, Err> {
|
||||
return this.repo.getById(fctx, id);
|
||||
}
|
||||
|
||||
create(
|
||||
fctx: FlowExecCtx,
|
||||
data: CreateSupportedApp,
|
||||
): ResultAsync<SupportedApp, Err> {
|
||||
return this.repo.create(fctx, data);
|
||||
}
|
||||
|
||||
update(
|
||||
fctx: FlowExecCtx,
|
||||
id: number,
|
||||
data: UpdateSupportedApp,
|
||||
): ResultAsync<SupportedApp, Err> {
|
||||
return this.repo.update(fctx, id, data);
|
||||
}
|
||||
|
||||
delete(fctx: FlowExecCtx, id: number): ResultAsync<boolean, Err> {
|
||||
return this.repo.delete(fctx, id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSupportedAppController(): SupportedAppController {
|
||||
return new SupportedAppController(new SupportedAppRepository(db));
|
||||
}
|
||||
28
packages/logic/domains/supported-app/data.ts
Normal file
28
packages/logic/domains/supported-app/data.ts
Normal file
@@ -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<typeof supportedAppSchema>;
|
||||
|
||||
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
|
||||
>;
|
||||
59
packages/logic/domains/supported-app/errors.ts
Normal file
59
packages/logic/domains/supported-app/errors.ts
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
149
packages/logic/domains/supported-app/repository.ts
Normal file
149
packages/logic/domains/supported-app/repository.ts
Normal file
@@ -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<SupportedApp[], Err> {
|
||||
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<SupportedApp, Err> {
|
||||
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<SupportedApp, Err> {
|
||||
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<SupportedApp, Err> {
|
||||
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<boolean, Err> {
|
||||
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),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
1
ws-scrcpy
Submodule
1
ws-scrcpy
Submodule
Submodule ws-scrcpy added at 49d2623184
Reference in New Issue
Block a user