From c31156be9f7c416027157270a317908a2fc95c87 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 19 May 2026 11:06:21 +0000 Subject: [PATCH] Task #609: Per-user app enable/disable in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `user_hidden_apps` table (userId+appId composite PK, cascade) in lib/db/src/schema/user-hidden-apps.ts; registered in schema index; pushed to dev DB via drizzle-kit. - Backend (artifacts/api-server/src/routes/apps.ts): - GET /me/apps — returns every globally-active app visible to the user with a `hidden` flag. - PUT /me/apps/:appId/hidden — toggles a row in user_hidden_apps, gated by getVisibleAppsForUser + isActive so a user can't toggle apps they can't reach. - GET /apps (Home/Dock) now uses getVisibleNonHiddenAppsForUser so hidden apps disappear immediately. - lib/appsVisibility.ts: added getHiddenAppIdsForUser and getVisibleNonHiddenAppsForUser helpers. - OpenAPI: added /me/apps + /me/apps/{appId}/hidden, MyApp and UpdateMyAppHiddenBody schemas; regenerated api-zod + api-client-react. - Frontend (settings-panel.tsx): added "My apps" accordion section as first GroupItem with MyAppsBody — Switch per app, optimistic update, invalidates getListMyAppsQueryKey + getListAppsQueryKey so Home/Dock refresh without reload. - Translations: added settingsPanel.section.myApps + settingsPanel.myApps in ar.json + en.json. - Code review fix: /me/apps and PUT gating filter isActive even for admins, so inactive apps don't appear in the Settings list. - Proposed follow-up #611 (Playwright test that hidden apps disappear from Home and Dock). --- .../api-server/src/lib/appsVisibility.ts | 29 +++ artifacts/api-server/src/routes/apps.ts | 70 +++++++- .../tx-os/src/components/settings-panel.tsx | 112 +++++++++++- artifacts/tx-os/src/locales/ar.json | 5 + artifacts/tx-os/src/locales/en.json | 5 + .../src/generated/api.schemas.ts | 9 + lib/api-client-react/src/generated/api.ts | 168 ++++++++++++++++++ lib/api-spec/openapi.yaml | 75 ++++++++ lib/api-zod/src/generated/api.ts | 145 +++++++++++++++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/user-hidden-apps.ts | 21 +++ 11 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 lib/db/src/schema/user-hidden-apps.ts diff --git a/artifacts/api-server/src/lib/appsVisibility.ts b/artifacts/api-server/src/lib/appsVisibility.ts index b9ee06fe..1a118b85 100644 --- a/artifacts/api-server/src/lib/appsVisibility.ts +++ b/artifacts/api-server/src/lib/appsVisibility.ts @@ -7,6 +7,7 @@ import { userAppOrdersTable, userGroupsTable, groupAppsTable, + userHiddenAppsTable, } from "@workspace/db"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { getEffectiveRoleIds } from "../middlewares/auth"; @@ -105,3 +106,31 @@ export async function getVisibleAppsForUser( groupGrantedSet.has(app.id), ); } + +/** + * 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 b29b9ece..8ec75619 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -16,6 +16,7 @@ import { auditLogsTable, permissionsTable, usersTable, + userHiddenAppsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth"; import { @@ -27,7 +28,11 @@ 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 } from "../lib/appsVisibility"; +import { + getVisibleAppsForUser, + getVisibleNonHiddenAppsForUser, + getHiddenAppIdsForUser, +} from "../lib/appsVisibility"; export { getVisibleAppsForUser }; import { CreateAppBody, @@ -42,10 +47,71 @@ const router: IRouter = Router(); router.get("/apps", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; - const visible = await getVisibleAppsForUser(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); 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/settings-panel.tsx b/artifacts/tx-os/src/components/settings-panel.tsx index 6f5e40d7..ae702bae 100644 --- a/artifacts/tx-os/src/components/settings-panel.tsx +++ b/artifacts/tx-os/src/components/settings-panel.tsx @@ -4,7 +4,14 @@ 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, @@ -132,6 +139,106 @@ 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. + */ +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")} +
+ ); + } + + return ( +
+ {apps.map((app) => { + const Icon = resolveAppIcon(app.iconName); + const name = lang === "ar" ? app.nameAr : app.nameEn; + const visible = !app.hidden; + return ( + + update.mutate({ appId: app.id, data: { hidden: visible } }) + } + label={name} + leftIcon={ + + } + /> + ); + })} +
+ ); +} + function SoundBody() { const { t } = useTranslation(); const { user } = useAuth(); @@ -154,7 +261,7 @@ function SoundBody() { ); } -type GroupId = "language" | "notifications" | "sound" | "clock"; +type GroupId = "myApps" | "language" | "notifications" | "sound" | "clock"; /** * Section wrapper that styles each accordion item like the existing @@ -219,6 +326,9 @@ function SettingsPanelBody({ onValueChange={(v) => onOpenChange(v as GroupId[])} className="space-y-2" > + + + diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 9c06afde..682005d6 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -193,6 +193,7 @@ "settingsPanel": { "title": "الإعدادات", "section": { + "myApps": "تطبيقاتي", "language": "اللغة", "notifications": "الإشعارات", "sound": "الصوت", @@ -204,6 +205,10 @@ }, "sound": { "master": "تشغيل أصوات الإشعارات" + }, + "myApps": { + "loading": "جارٍ تحميل التطبيقات...", + "empty": "لا توجد تطبيقات متاحة." } }, "notifSettings": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 1d87c4dc..2842726c 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -199,6 +199,7 @@ "settingsPanel": { "title": "Settings", "section": { + "myApps": "My apps", "language": "Language", "notifications": "Notifications", "sound": "Sound", @@ -210,6 +211,10 @@ }, "sound": { "master": "Play notification sounds" + }, + "myApps": { + "loading": "Loading apps…", + "empty": "No apps available." } }, "notifSettings": { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d1dc49fb..5124b183 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -763,6 +763,15 @@ 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 f9a4843b..d184374b 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -80,6 +80,7 @@ import type { ListReceivedNotesParams, ListUsersParams, LoginBody, + MyApp, MyNote, NoteFolder, NoteReply, @@ -118,6 +119,7 @@ import type { UpdateClockStyleBody, UpdateGroupBody, UpdateLanguageBody, + UpdateMyAppHiddenBody, UpdateMyAppOrderBody, UpdateNoteFolderBody, UpdateNoteFolderParams, @@ -2252,6 +2254,172 @@ 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 9c1a4de9..e896c855 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -633,6 +633,64 @@ 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: @@ -4231,6 +4289,23 @@ 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 e84bd146..8920be23 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -994,6 +994,151 @@ 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 25a67738..f9494398 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -6,6 +6,7 @@ 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 new file mode 100644 index 00000000..c09e17e8 --- /dev/null +++ b/lib/db/src/schema/user-hidden-apps.ts @@ -0,0 +1,21 @@ +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;