Task #613: replace per-app hide toggles with single Dock visibility switch

The per-app hidden feature shipped in #609 was rejected — user wanted one
toggle that hides/shows the entire bottom AppDock bar, leaving Home apps
untouched.

Removed:
- lib/db/src/schema/user-hidden-apps.ts (and index.ts export); table
  dropped via `pnpm --filter @workspace/db run push`
- GET /me/apps and PUT /me/apps/:appId/hidden routes
- getHiddenAppIdsForUser + getVisibleNonHiddenAppsForUser helpers
- MyApp + UpdateMyAppHiddenBody OpenAPI schemas
- MyAppsBody settings UI + related i18n keys
- Regenerated api-client-react + api-zod from the trimmed spec
- Reverted GET /apps back to getVisibleAppsForUser

Added:
- artifacts/tx-os/src/hooks/use-dock-visible.ts — localStorage-backed
  preference with a custom window event for in-tab sync and the native
  `storage` event for cross-tab sync. Default = true.
- DockBody in settings-panel: single ToggleRow under a new "App dock"
  section ("settingsPanel.section.dock" / "settingsPanel.dock.show")
- AppDock now returns null when the preference is off and clears
  `--app-dock-height` so page padding doesn't stay reserved.

Code review: PASS (architect). No remaining references to the removed
infra. Typecheck shows only pre-existing errors in executive-meetings.ts
and push.ts unrelated to this change.

Follow-up: publish to Gitea + redeploy on Mac (proposed as a follow-up
task).
This commit is contained in:
riyadhafraa
2026-05-19 11:35:13 +00:00
parent f1536cb4db
commit 3f6a0fb02f
13 changed files with 95 additions and 638 deletions
@@ -7,7 +7,6 @@ import {
userAppOrdersTable,
userGroupsTable,
groupAppsTable,
userHiddenAppsTable,
} from "@workspace/db";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { getEffectiveRoleIds } from "../middlewares/auth";
@@ -107,30 +106,3 @@ export async function getVisibleAppsForUser(
);
}
/**
* Returns the set of app IDs the given user has personally hidden from
* their Home/Dock via PUT /me/apps/{appId}/hidden.
*/
export async function getHiddenAppIdsForUser(
userId: number,
): Promise<Set<number>> {
const rows = await db
.select({ appId: userHiddenAppsTable.appId })
.from(userHiddenAppsTable)
.where(eq(userHiddenAppsTable.userId, userId));
return new Set(rows.map((r) => r.appId));
}
/**
* Like getVisibleAppsForUser but additionally strips apps the user has
* personally hidden. Used by GET /apps which backs Home + Dock so the
* hidden toggle in Settings takes effect immediately.
*/
export async function getVisibleNonHiddenAppsForUser(
userId: number,
): Promise<typeof appsTable.$inferSelect[]> {
const visible = await getVisibleAppsForUser(userId);
const hidden = await getHiddenAppIdsForUser(userId);
if (hidden.size === 0) return visible;
return visible.filter((a) => !hidden.has(a.id));
}
+2 -68
View File
@@ -16,7 +16,6 @@ import {
auditLogsTable,
permissionsTable,
usersTable,
userHiddenAppsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import {
@@ -28,11 +27,7 @@ import { emitAppsChangedToPermissionHolders } from "../lib/realtime";
// Re-exported from lib/appsVisibility so storage authorization
// (lib/objectAuthz.ts) can use the exact same RBAC without taking a
// dependency on this routes module.
import {
getVisibleAppsForUser,
getVisibleNonHiddenAppsForUser,
getHiddenAppIdsForUser,
} from "../lib/appsVisibility";
import { getVisibleAppsForUser } from "../lib/appsVisibility";
export { getVisibleAppsForUser };
import {
CreateAppBody,
@@ -47,71 +42,10 @@ const router: IRouter = Router();
router.get("/apps", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
// Home + Dock want the user's effective list, so apply the per-user
// hidden filter here. The Settings panel calls GET /me/apps below to
// see the full unfiltered list with the hidden flag for toggling.
const visible = await getVisibleNonHiddenAppsForUser(userId);
const visible = await getVisibleAppsForUser(userId);
res.json(visible);
});
router.get("/me/apps", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
// Exclude globally-inactive apps even for admins — the per-user
// Settings list is "which of my apps do I want visible", and the
// user can't reach inactive apps from Home/Dock anyway.
const visible = (await getVisibleAppsForUser(userId)).filter(
(a) => a.isActive,
);
const hidden = await getHiddenAppIdsForUser(userId);
res.json(visible.map((a) => ({ ...a, hidden: hidden.has(a.id) })));
});
router.put(
"/me/apps/:appId/hidden",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const appId = Number(req.params.appId);
if (!Number.isInteger(appId) || appId <= 0) {
res.status(400).json({ error: "Invalid appId" });
return;
}
const body = req.body as { hidden?: unknown } | undefined;
if (typeof body?.hidden !== "boolean") {
res.status(400).json({ error: "hidden must be a boolean" });
return;
}
// Gate on the user's actual visibility so a non-admin can't toggle
// a hidden flag for an app they shouldn't even know exists.
// Also require the app to be globally active so we don't persist
// user_hidden_apps rows for apps the user can't reach anyway.
const visible = (await getVisibleAppsForUser(userId)).filter(
(a) => a.isActive,
);
const app = visible.find((a) => a.id === appId);
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
if (body.hidden) {
await db
.insert(userHiddenAppsTable)
.values({ userId, appId })
.onConflictDoNothing();
} else {
await db
.delete(userHiddenAppsTable)
.where(
and(
eq(userHiddenAppsTable.userId, userId),
eq(userHiddenAppsTable.appId, appId),
),
);
}
res.json({ ...app, hidden: body.hidden });
},
);
router.get("/admin/apps", requireAdmin, async (_req, res): Promise<void> => {
const allApps = await db
.select()
+6 -3
View File
@@ -9,6 +9,7 @@ import {
logAppOpen,
type App,
} from "@workspace/api-client-react";
import { useDockVisible } from "@/hooks/use-dock-visible";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
@@ -34,6 +35,7 @@ export function AppDock({ currentSlug }: AppDockProps) {
const lang = i18n.language;
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const dockRef = useRef<HTMLDivElement | null>(null);
const [dockEnabled] = useDockVisible();
// Match Home's source-of-truth contract: same `useListApps` query
// key and the same `isActive` filter Home applies in its
@@ -45,9 +47,10 @@ export function AppDock({ currentSlug }: AppDockProps) {
(a) => a.isActive && a.slug !== currentSlug,
);
// Skip the degenerate single-button dock: if the user only has one
// other app to switch to, the dock adds no real value.
const visible = others.length > 1;
// Hide the dock when:
// - the user disabled it in Settings (dockEnabled === false), or
// - it would be degenerate (≤1 other app to switch to).
const visible = dockEnabled && others.length > 1;
// Publish the dock's measured outer height as `--app-dock-height` on
// the document root so the global `body { padding-bottom }` rule in
+22 -108
View File
@@ -4,18 +4,12 @@ import { useQueryClient } from "@tanstack/react-query";
import {
useUpdateLanguage,
getGetMeQueryKey,
useListMyApps,
useUpdateMyAppHidden,
getListMyAppsQueryKey,
getListAppsQueryKey,
type MyApp,
} from "@workspace/api-client-react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
Settings as SettingsIcon,
Volume2,
VolumeX,
LayoutGrid,
} from "lucide-react";
import {
Sheet,
@@ -41,6 +35,7 @@ import {
} from "@/components/notification-settings";
import { ClockStyleContent } from "@/components/clock-style-picker";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useDockVisible } from "@/hooks/use-dock-visible";
import i18n from "@/i18n";
function ToggleRow({
@@ -141,107 +136,26 @@ function LanguageBody() {
);
}
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
return x in LucideIcons;
}
function resolveAppIcon(name: string): LucideIcon {
if (isIconName(name)) {
const Candidate = LucideIcons[name] as unknown;
if (typeof Candidate === "function" || typeof Candidate === "object") {
return Candidate as LucideIcon;
}
}
return LucideIcons.Grid2X2;
}
/**
* Per-user Home/Dock app visibility. Each row is a Switch that toggles a
* row in `user_hidden_apps` for the current user. The mutation invalidates
* both this list and the GET /apps query that backs Home + Dock so the
* change reflects everywhere immediately without a reload.
* Single toggle to hide/show the bottom AppDock bar.
* Preference is per-device (localStorage) and the AppDock listens to
* the same hook so the change is instant without a reload.
*/
function MyAppsBody() {
const { t, i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const queryClient = useQueryClient();
const { data: apps, isLoading } = useListMyApps({
query: { queryKey: getListMyAppsQueryKey() },
});
const update = useUpdateMyAppHidden({
mutation: {
onMutate: async ({ appId, data }) => {
const key = getListMyAppsQueryKey();
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<MyApp[]>(key);
if (previous) {
queryClient.setQueryData<MyApp[]>(
key,
previous.map((a) =>
a.id === appId ? { ...a, hidden: data.hidden } : a,
),
);
}
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(getListMyAppsQueryKey(), ctx.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: getListMyAppsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
},
},
});
if (isLoading) {
return (
<div className="px-3 py-4 text-sm text-muted-foreground">
{t("settingsPanel.myApps.loading")}
</div>
);
}
if (!apps || apps.length === 0) {
return (
<div className="px-3 py-4 text-sm text-muted-foreground">
{t("settingsPanel.myApps.empty")}
</div>
);
}
function DockBody() {
const { t } = useTranslation();
const [visible, setVisible] = useDockVisible();
return (
<div className="flex flex-col gap-0.5">
{apps.map((app) => {
const Icon = resolveAppIcon(app.iconName);
const name = lang === "ar" ? app.nameAr : app.nameEn;
const visible = !app.hidden;
const ariaLabel = visible
? t("settingsPanel.myApps.hide", { name })
: t("settingsPanel.myApps.show", { name });
return (
<ToggleRow
key={app.id}
active={visible}
onClick={() =>
update.mutate({ appId: app.id, data: { hidden: visible } })
}
label={name}
ariaLabel={ariaLabel}
leftIcon={
<span
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: `${app.color}20`, color: app.color }}
aria-hidden="true"
>
<Icon size={14} />
</span>
}
/>
);
})}
</div>
<ToggleRow
active={visible}
onClick={() => setVisible(!visible)}
label={t("settingsPanel.dock.show")}
leftIcon={
<LayoutGrid
size={16}
className={visible ? "text-primary shrink-0" : "text-muted-foreground shrink-0"}
/>
}
/>
);
}
@@ -267,7 +181,7 @@ function SoundBody() {
);
}
type GroupId = "myApps" | "language" | "notifications" | "sound" | "clock";
type GroupId = "dock" | "language" | "notifications" | "sound" | "clock";
/**
* Section wrapper that styles each accordion item like the existing
@@ -332,8 +246,8 @@ function SettingsPanelBody({
onValueChange={(v) => onOpenChange(v as GroupId[])}
className="space-y-2"
>
<GroupItem id="myApps" title={t("settingsPanel.section.myApps")}>
<MyAppsBody />
<GroupItem id="dock" title={t("settingsPanel.section.dock")}>
<DockBody />
</GroupItem>
<GroupItem id="language" title={t("settingsPanel.section.language")}>
<LanguageBody />
@@ -0,0 +1,59 @@
import { useEffect, useState, useCallback } from "react";
const STORAGE_KEY = "tx.dockVisible";
const EVENT_NAME = "tx:dock-visibility-changed";
function readPref(): boolean {
if (typeof window === "undefined") return true;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) return true;
return raw !== "false";
} catch {
return true;
}
}
/**
* Reads the per-device "show app dock" preference and stays in sync
* with other components that toggle it within the same tab (via a
* custom event) or in another tab (via the native `storage` event).
* Default = true (dock visible).
*/
export function useDockVisible(): [boolean, (next: boolean) => void] {
const [visible, setVisible] = useState<boolean>(() => readPref());
useEffect(() => {
const onCustom = (e: Event) => {
const ce = e as CustomEvent<{ visible: boolean }>;
if (typeof ce.detail?.visible === "boolean") {
setVisible(ce.detail.visible);
} else {
setVisible(readPref());
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) setVisible(readPref());
};
window.addEventListener(EVENT_NAME, onCustom);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(EVENT_NAME, onCustom);
window.removeEventListener("storage", onStorage);
};
}, []);
const set = useCallback((next: boolean) => {
try {
window.localStorage.setItem(STORAGE_KEY, next ? "true" : "false");
} catch {
// ignore quota / private-mode errors; in-memory state still updates
}
setVisible(next);
window.dispatchEvent(
new CustomEvent(EVENT_NAME, { detail: { visible: next } }),
);
}, []);
return [visible, set];
}
+3 -6
View File
@@ -193,7 +193,7 @@
"settingsPanel": {
"title": "الإعدادات",
"section": {
"myApps": "تطبيقاتي",
"dock": "شريط التطبيقات",
"language": "اللغة",
"notifications": "الإشعارات",
"sound": "الصوت",
@@ -206,11 +206,8 @@
"sound": {
"master": "تشغيل أصوات الإشعارات"
},
"myApps": {
"loading": "جارٍ تحميل التطبيقات...",
"empty": "لا توجد تطبيقات متاحة.",
"hide": "إخفاء {{name}}",
"show": "إظهار {{name}}"
"dock": {
"show": "إظهار شريط التطبيقات السفلي"
}
},
"notifSettings": {
+3 -6
View File
@@ -199,7 +199,7 @@
"settingsPanel": {
"title": "Settings",
"section": {
"myApps": "My apps",
"dock": "App dock",
"language": "Language",
"notifications": "Notifications",
"sound": "Sound",
@@ -212,11 +212,8 @@
"sound": {
"master": "Play notification sounds"
},
"myApps": {
"loading": "Loading apps…",
"empty": "No apps available.",
"hide": "Hide {{name}}",
"show": "Show {{name}}"
"dock": {
"show": "Show bottom app dock"
}
},
"notifSettings": {
@@ -763,15 +763,6 @@ export interface AppSettings {
updatedAt?: string;
}
export type MyApp = App & {
/** True when the current user has hidden this app from their Home/Dock. */
hidden: boolean;
};
export interface UpdateMyAppHiddenBody {
hidden: boolean;
}
export interface UpdateMyAppOrderBody {
/** App IDs in the desired display order */
order: number[];
-168
View File
@@ -80,7 +80,6 @@ import type {
ListReceivedNotesParams,
ListUsersParams,
LoginBody,
MyApp,
MyNote,
NoteFolder,
NoteReply,
@@ -119,7 +118,6 @@ import type {
UpdateClockStyleBody,
UpdateGroupBody,
UpdateLanguageBody,
UpdateMyAppHiddenBody,
UpdateMyAppOrderBody,
UpdateNoteFolderBody,
UpdateNoteFolderParams,
@@ -2254,172 +2252,6 @@ export const useUpdateMyAppOrder = <
return useMutation(getUpdateMyAppOrderMutationOptions(options));
};
/**
* Returns every globally active app (regardless of the per-user hidden
flag), each with a `hidden` boolean indicating whether the current
user has hidden it from their own Home/Dock via PUT /me/apps/{appId}/hidden.
Used by the per-user Settings panel; the Home and Dock continue to use
GET /apps which already filters out hidden apps server-side.
* @summary List apps for the current user with their per-user hidden state
*/
export const getListMyAppsUrl = () => {
return `/api/me/apps`;
};
export const listMyApps = async (options?: RequestInit): Promise<MyApp[]> => {
return customFetch<MyApp[]>(getListMyAppsUrl(), {
...options,
method: "GET",
});
};
export const getListMyAppsQueryKey = () => {
return [`/api/me/apps`] as const;
};
export const getListMyAppsQueryOptions = <
TData = Awaited<ReturnType<typeof listMyApps>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMyApps>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMyAppsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMyApps>>> = ({
signal,
}) => listMyApps({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMyApps>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMyAppsQueryResult = NonNullable<
Awaited<ReturnType<typeof listMyApps>>
>;
export type ListMyAppsQueryError = ErrorType<unknown>;
/**
* @summary List apps for the current user with their per-user hidden state
*/
export function useListMyApps<
TData = Awaited<ReturnType<typeof listMyApps>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMyApps>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMyAppsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Hide or show an app for the current user
*/
export const getUpdateMyAppHiddenUrl = (appId: number) => {
return `/api/me/apps/${appId}/hidden`;
};
export const updateMyAppHidden = async (
appId: number,
updateMyAppHiddenBody: UpdateMyAppHiddenBody,
options?: RequestInit,
): Promise<MyApp> => {
return customFetch<MyApp>(getUpdateMyAppHiddenUrl(appId), {
...options,
method: "PUT",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateMyAppHiddenBody),
});
};
export const getUpdateMyAppHiddenMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
> => {
const mutationKey = ["updateMyAppHidden"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateMyAppHidden>>,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> }
> = (props) => {
const { appId, data } = props ?? {};
return updateMyAppHidden(appId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMyAppHiddenMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMyAppHidden>>
>;
export type UpdateMyAppHiddenMutationBody = BodyType<UpdateMyAppHiddenBody>;
export type UpdateMyAppHiddenMutationError = ErrorType<ErrorResponse>;
/**
* @summary Hide or show an app for the current user
*/
export const useUpdateMyAppHidden = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
> => {
return useMutation(getUpdateMyAppHiddenMutationOptions(options));
};
/**
* @summary Get application settings (public)
*/
-75
View File
@@ -633,64 +633,6 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/me/apps:
get:
operationId: listMyApps
tags: [apps]
summary: List apps for the current user with their per-user hidden state
description: |
Returns every globally active app (regardless of the per-user hidden
flag), each with a `hidden` boolean indicating whether the current
user has hidden it from their own Home/Dock via PUT /me/apps/{appId}/hidden.
Used by the per-user Settings panel; the Home and Dock continue to use
GET /apps which already filters out hidden apps server-side.
responses:
"200":
description: Apps visible to the current user, with per-user hidden state
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/MyApp"
/me/apps/{appId}/hidden:
put:
operationId: updateMyAppHidden
tags: [apps]
summary: Hide or show an app for the current user
parameters:
- name: appId
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateMyAppHiddenBody"
responses:
"200":
description: Updated per-user app state
content:
application/json:
schema:
$ref: "#/components/schemas/MyApp"
"400":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: App not found or not visible to the current user
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Settings
/settings:
get:
@@ -4289,23 +4231,6 @@ components:
type: string
format: date-time
MyApp:
allOf:
- $ref: "#/components/schemas/App"
- type: object
required: [hidden]
properties:
hidden:
type: boolean
description: True when the current user has hidden this app from their Home/Dock.
UpdateMyAppHiddenBody:
type: object
required: [hidden]
properties:
hidden:
type: boolean
UpdateMyAppOrderBody:
type: object
required: [order]
-145
View File
@@ -994,151 +994,6 @@ export const UpdateMyAppOrderResponseItem = zod.object({
});
export const UpdateMyAppOrderResponse = zod.array(UpdateMyAppOrderResponseItem);
/**
* Returns every globally active app (regardless of the per-user hidden
flag), each with a `hidden` boolean indicating whether the current
user has hidden it from their own Home/Dock via PUT /me/apps/{appId}/hidden.
Used by the per-user Settings panel; the Home and Dock continue to use
GET /apps which already filters out hidden apps server-side.
* @summary List apps for the current user with their per-user hidden state
*/
export const ListMyAppsResponseItem = zod
.object({
id: zod.number(),
slug: zod.string(),
nameAr: zod.string(),
nameEn: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
iconName: zod.string(),
imageUrl: zod
.string()
.nullish()
.describe(
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
),
route: zod.string(),
externalUrl: zod
.string()
.nullish()
.describe(
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
),
openMode: zod
.enum(["internal", "external_tab", "external_iframe"])
.describe(
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
),
color: zod.string(),
isActive: zod.boolean(),
isSystem: zod.boolean(),
sortOrder: zod.number(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
groupCount: zod
.number()
.optional()
.describe(
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
),
restrictionCount: zod
.number()
.optional()
.describe(
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
),
openCount: zod
.number()
.optional()
.describe(
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
),
})
.and(
zod.object({
hidden: zod
.boolean()
.describe(
"True when the current user has hidden this app from their Home\/Dock.",
),
}),
);
export const ListMyAppsResponse = zod.array(ListMyAppsResponseItem);
/**
* @summary Hide or show an app for the current user
*/
export const UpdateMyAppHiddenParams = zod.object({
appId: zod.coerce.number(),
});
export const UpdateMyAppHiddenBody = zod.object({
hidden: zod.boolean(),
});
export const UpdateMyAppHiddenResponse = zod
.object({
id: zod.number(),
slug: zod.string(),
nameAr: zod.string(),
nameEn: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
iconName: zod.string(),
imageUrl: zod
.string()
.nullish()
.describe(
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
),
route: zod.string(),
externalUrl: zod
.string()
.nullish()
.describe(
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
),
openMode: zod
.enum(["internal", "external_tab", "external_iframe"])
.describe(
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
),
color: zod.string(),
isActive: zod.boolean(),
isSystem: zod.boolean(),
sortOrder: zod.number(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
groupCount: zod
.number()
.optional()
.describe(
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
),
restrictionCount: zod
.number()
.optional()
.describe(
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
),
openCount: zod
.number()
.optional()
.describe(
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
),
})
.and(
zod.object({
hidden: zod
.boolean()
.describe(
"True when the current user has hidden this app from their Home\/Dock.",
),
}),
);
/**
* @summary Get application settings (public)
*/
-1
View File
@@ -6,7 +6,6 @@ export * from "./service-orders";
export * from "./notifications";
export * from "./settings";
export * from "./user-app-orders";
export * from "./user-hidden-apps";
export * from "./app-opens";
export * from "./password-reset-tokens";
export * from "./notes";
-21
View File
@@ -1,21 +0,0 @@
import { pgTable, integer, primaryKey, timestamp } from "drizzle-orm/pg-core";
import { usersTable } from "./users";
import { appsTable } from "./apps";
export const userHiddenAppsTable = pgTable(
"user_hidden_apps",
{
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
appId: integer("app_id")
.notNull()
.references(() => appsTable.id, { onDelete: "cascade" }),
hiddenAt: timestamp("hidden_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.appId] })],
);
export type UserHiddenApp = typeof userHiddenAppsTable.$inferSelect;