Task #517: App image upload, external links, built-in route lock

Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required by the form when an external mode
  is chosen.
- Route field is locked (readOnly + lock hint) when editing a built-in
  app, since those slugs are hardcoded in the SPA router.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug, re-exported from lib/db.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering
  reject built-in route change, allow non-route built-in updates,
  allow non-builtin route changes, allow built-in same-route no-op.

Notes / drift:
- BUILTIN_APP_SLUGS is duplicated inline in admin.tsx
  (BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import
  @workspace/db (pulls pg). Comment points at the canonical source;
  drift risk filed as a follow-up.
- Pre-existing failures unrelated to this task: 3 tests in
  executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts. Out of scope.
This commit is contained in:
riyadhafraa
2026-05-12 12:23:38 +00:00
parent d8baac9317
commit a794f92e61
14 changed files with 758 additions and 12 deletions
@@ -415,6 +415,21 @@ export interface UpdateGroupBody {
userIds?: number[];
}
/**
* How the launcher opens this app: "internal" navigates to `route`
inside the SPA; "external_tab" opens externalUrl in a new tab;
"external_iframe" navigates to /embedded/:id, which renders
externalUrl inside an iframe shell.
*/
export type AppOpenMode = (typeof AppOpenMode)[keyof typeof AppOpenMode];
export const AppOpenMode = {
internal: "internal",
external_tab: "external_tab",
external_iframe: "external_iframe",
} as const;
export interface App {
id: number;
slug: string;
@@ -425,7 +440,28 @@ export interface App {
/** @nullable */
descriptionEn?: string | null;
iconName: string;
/**
* Optional uploaded image used in place of the Lucide icon on the
launcher tile. Stored as the object-storage path returned by the
upload presigner (e.g. "/objects/<id>").
* @nullable
*/
imageUrl?: string | null;
route: string;
/**
* For openMode "external_tab" or "external_iframe": the URL the
launcher should open. Ignored when openMode is "internal".
* @nullable
*/
externalUrl?: string | null;
/** How the launcher opens this app: "internal" navigates to `route`
inside the SPA; "external_tab" opens externalUrl in a new tab;
"external_iframe" navigates to /embedded/:id, which renders
externalUrl inside an iframe shell.
*/
openMode: AppOpenMode;
color: string;
isActive: boolean;
isSystem: boolean;
@@ -448,6 +484,15 @@ the admin list endpoint (GET /admin/apps).
openCount?: number;
}
export type CreateAppBodyOpenMode =
(typeof CreateAppBodyOpenMode)[keyof typeof CreateAppBodyOpenMode];
export const CreateAppBodyOpenMode = {
internal: "internal",
external_tab: "external_tab",
external_iframe: "external_iframe",
} as const;
export interface CreateAppBody {
slug: string;
nameAr: string;
@@ -457,7 +502,12 @@ export interface CreateAppBody {
/** @nullable */
descriptionEn?: string | null;
iconName: string;
/** @nullable */
imageUrl?: string | null;
route: string;
/** @nullable */
externalUrl?: string | null;
openMode?: CreateAppBodyOpenMode;
color: string;
isActive?: boolean;
isSystem?: boolean;
@@ -466,6 +516,15 @@ export interface CreateAppBody {
permissionIds?: number[];
}
export type UpdateAppBodyOpenMode =
(typeof UpdateAppBodyOpenMode)[keyof typeof UpdateAppBodyOpenMode];
export const UpdateAppBodyOpenMode = {
internal: "internal",
external_tab: "external_tab",
external_iframe: "external_iframe",
} as const;
export interface UpdateAppBody {
nameAr?: string;
nameEn?: string;
@@ -474,7 +533,12 @@ export interface UpdateAppBody {
/** @nullable */
descriptionEn?: string | null;
iconName?: string;
/** @nullable */
imageUrl?: string | null;
route?: string;
/** @nullable */
externalUrl?: string | null;
openMode?: UpdateAppBodyOpenMode;
color?: string;
isActive?: boolean;
sortOrder?: number;
+34
View File
@@ -3626,8 +3626,27 @@ components:
type: ["string", "null"]
iconName:
type: string
imageUrl:
type: ["string", "null"]
description: |
Optional uploaded image used in place of the Lucide icon on the
launcher tile. Stored as the object-storage path returned by the
upload presigner (e.g. "/objects/<id>").
route:
type: string
externalUrl:
type: ["string", "null"]
description: |
For openMode "external_tab" or "external_iframe": the URL the
launcher should open. Ignored when openMode is "internal".
openMode:
type: string
enum: [internal, external_tab, external_iframe]
description: |
How the launcher opens this app: "internal" navigates to `route`
inside the SPA; "external_tab" opens externalUrl in a new tab;
"external_iframe" navigates to /embedded/:id, which renders
externalUrl inside an iframe shell.
color:
type: string
isActive:
@@ -3666,6 +3685,7 @@ components:
- nameEn
- iconName
- route
- openMode
- color
- isActive
- isSystem
@@ -3688,8 +3708,15 @@ components:
type: ["string", "null"]
iconName:
type: string
imageUrl:
type: ["string", "null"]
route:
type: string
externalUrl:
type: ["string", "null"]
openMode:
type: string
enum: [internal, external_tab, external_iframe]
color:
type: string
isActive:
@@ -3724,8 +3751,15 @@ components:
type: ["string", "null"]
iconName:
type: string
imageUrl:
type: ["string", "null"]
route:
type: string
externalUrl:
type: ["string", "null"]
openMode:
type: string
enum: [internal, external_tab, external_iframe]
color:
type: string
isActive:
+78
View File
@@ -562,7 +562,24 @@ export const ListAppsResponseItem = zod.object({
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(),
@@ -600,7 +617,12 @@ export const CreateAppBody = zod.object({
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
iconName: zod.string(),
imageUrl: zod.string().nullish(),
route: zod.string(),
externalUrl: zod.string().nullish(),
openMode: zod
.enum(["internal", "external_tab", "external_iframe"])
.optional(),
color: zod.string(),
isActive: zod.boolean().optional(),
isSystem: zod.boolean().optional(),
@@ -628,7 +650,24 @@ export const GetAppResponse = zod.object({
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(),
@@ -668,7 +707,12 @@ export const UpdateAppBody = zod.object({
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
iconName: zod.string().optional(),
imageUrl: zod.string().nullish(),
route: zod.string().optional(),
externalUrl: zod.string().nullish(),
openMode: zod
.enum(["internal", "external_tab", "external_iframe"])
.optional(),
color: zod.string().optional(),
isActive: zod.boolean().optional(),
sortOrder: zod.number().optional(),
@@ -682,7 +726,24 @@ export const UpdateAppResponse = zod.object({
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(),
@@ -852,7 +913,24 @@ export const UpdateMyAppOrderResponseItem = zod.object({
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(),
+21
View File
@@ -0,0 +1,21 @@
// Slugs of apps whose `route` is hardcoded in the tx-os SPA router
// (see artifacts/tx-os/src/App.tsx). Editing the route of one of these
// apps in the admin panel breaks navigation silently — the launcher
// would set the new path but no <Route> matches it. Task #517: server
// rejects route changes for these slugs and the admin form locks the
// route field with a helpful notice. Add a slug here whenever you wire
// a new hardcoded <Route> in App.tsx for a seeded app.
export const BUILTIN_APP_SLUGS = [
"services",
"notifications",
"admin",
"notes",
"my-orders",
"executive-meetings",
] as const;
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
export function isBuiltinAppSlug(slug: string): slug is BuiltinAppSlug {
return (BUILTIN_APP_SLUGS as readonly string[]).includes(slug);
}
+1
View File
@@ -14,3 +14,4 @@ export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
export * from "./schema";
export * from "./built-in-apps";
+13
View File
@@ -10,7 +10,20 @@ export const appsTable = pgTable("apps", {
descriptionAr: text("description_ar"),
descriptionEn: text("description_en"),
iconName: varchar("icon_name", { length: 100 }).notNull().default("Grid"),
// Optional uploaded image used in place of the Lucide icon on the
// launcher tile. Stored as the object-storage path (e.g. "/objects/<id>")
// returned by the upload presigner, so resolveServiceImageUrl knows how
// to translate it for the browser.
imageUrl: varchar("image_url", { length: 500 }),
route: varchar("route", { length: 500 }).notNull(),
// For openMode "external_tab"|"external_iframe": the target URL.
// Ignored when openMode is "internal".
externalUrl: varchar("external_url", { length: 1000 }),
// How the launcher should open this app:
// internal — navigate to `route` inside the SPA (default)
// external_tab — window.open(externalUrl, "_blank")
// external_iframe— navigate to /embedded/:id, which renders externalUrl in an iframe shell
openMode: varchar("open_mode", { length: 20 }).notNull().default("internal"),
color: varchar("color", { length: 50 }).notNull().default("#6366f1"),
isActive: boolean("is_active").notNull().default(true),
isSystem: boolean("is_system").notNull().default(false),