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:
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user