diff --git a/artifacts/api-server/src/lib/appsVisibility.ts b/artifacts/api-server/src/lib/appsVisibility.ts index 1a118b85..5da4f2a3 100644 --- a/artifacts/api-server/src/lib/appsVisibility.ts +++ b/artifacts/api-server/src/lib/appsVisibility.ts @@ -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> { - 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 { - const visible = await getVisibleAppsForUser(userId); - const hidden = await getHiddenAppIdsForUser(userId); - if (hidden.size === 0) return visible; - return visible.filter((a) => !hidden.has(a.id)); -} diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 8ec75619..b29b9ece 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -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 => { 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 => { - 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 => { - 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 => { const allApps = await db .select() diff --git a/artifacts/tx-os/src/components/app-dock.tsx b/artifacts/tx-os/src/components/app-dock.tsx index bf917322..b47b869d 100644 --- a/artifacts/tx-os/src/components/app-dock.tsx +++ b/artifacts/tx-os/src/components/app-dock.tsx @@ -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(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 diff --git a/artifacts/tx-os/src/components/settings-panel.tsx b/artifacts/tx-os/src/components/settings-panel.tsx index 4567f24d..5f40296f 100644 --- a/artifacts/tx-os/src/components/settings-panel.tsx +++ b/artifacts/tx-os/src/components/settings-panel.tsx @@ -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(key); - if (previous) { - queryClient.setQueryData( - 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 ( -
- {t("settingsPanel.myApps.loading")} -
- ); - } - if (!apps || apps.length === 0) { - return ( -
- {t("settingsPanel.myApps.empty")} -
- ); - } - +function DockBody() { + const { t } = useTranslation(); + const [visible, setVisible] = useDockVisible(); return ( -
- {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 ( - - update.mutate({ appId: app.id, data: { hidden: visible } }) - } - label={name} - ariaLabel={ariaLabel} - leftIcon={ - - } - /> - ); - })} -
+ setVisible(!visible)} + label={t("settingsPanel.dock.show")} + leftIcon={ + + } + /> ); } @@ -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" > - - + + diff --git a/artifacts/tx-os/src/hooks/use-dock-visible.ts b/artifacts/tx-os/src/hooks/use-dock-visible.ts new file mode 100644 index 00000000..ace12fca --- /dev/null +++ b/artifacts/tx-os/src/hooks/use-dock-visible.ts @@ -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(() => 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]; +} diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 4732d886..61f21aa3 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index c600cdcd..f160cb66 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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": { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 5124b183..d1dc49fb 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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[]; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index d184374b..f9a4843b 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { - return customFetch(getListMyAppsUrl(), { - ...options, - method: "GET", - }); -}; - -export const getListMyAppsQueryKey = () => { - return [`/api/me/apps`] as const; -}; - -export const getListMyAppsQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListMyAppsQueryKey(); - - const queryFn: QueryFunction>> = ({ - signal, - }) => listMyApps({ signal, ...requestOptions }); - - return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type ListMyAppsQueryResult = NonNullable< - Awaited> ->; -export type ListMyAppsQueryError = ErrorType; - -/** - * @summary List apps for the current user with their per-user hidden state - */ - -export function useListMyApps< - TData = Awaited>, - TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getListMyAppsQueryOptions(options); - - const query = useQuery(queryOptions) as UseQueryResult & { - 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 => { - return customFetch(getUpdateMyAppHiddenUrl(appId), { - ...options, - method: "PUT", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(updateMyAppHiddenBody), - }); -}; - -export const getUpdateMyAppHiddenMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { appId: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { appId: number; data: BodyType }, - 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>, - { appId: number; data: BodyType } - > = (props) => { - const { appId, data } = props ?? {}; - - return updateMyAppHidden(appId, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type UpdateMyAppHiddenMutationResult = NonNullable< - Awaited> ->; -export type UpdateMyAppHiddenMutationBody = BodyType; -export type UpdateMyAppHiddenMutationError = ErrorType; - -/** - * @summary Hide or show an app for the current user - */ -export const useUpdateMyAppHidden = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { appId: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { appId: number; data: BodyType }, - TContext -> => { - return useMutation(getUpdateMyAppHiddenMutationOptions(options)); -}; - /** * @summary Get application settings (public) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index e896c855..9c1a4de9 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 8920be23..e84bd146 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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\/\").\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\/\").\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) */ diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index f9494398..25a67738 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -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"; diff --git a/lib/db/src/schema/user-hidden-apps.ts b/lib/db/src/schema/user-hidden-apps.ts deleted file mode 100644 index c09e17e8..00000000 --- a/lib/db/src/schema/user-hidden-apps.ts +++ /dev/null @@ -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;