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 when an external mode is chosen.
  Internal route field is hidden entirely when an external mode is
  selected, and locked (readOnly + lock hint) when editing a built-in
  app whose path is hardcoded in the SPA.

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. Exposed via subpath export
  `@workspace/db/built-in-apps`; the file has zero imports so the
  browser bundle can use it without pulling in `pg`. tx-os now imports
  it directly — duplicate FE constant removed.
- Built-in slug list: services, notifications, admin, notes,
  my-orders, orders-incoming, executive-meetings (everything with a
  hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar /
  documents are seeded but admin-defined and remain editable.
- 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.*.
- scripts/src/seed.ts: drift guard throws if a seeded built-in slug
  uses a route that does not match the hardcoded SPA route.

Tests:
- New API test apps-builtin-route-lock.test.mjs (4/4 pass): reject
  built-in route change, allow non-route built-in updates, allow
  non-builtin route changes, allow built-in same-route no-op.
- New Playwright E2E admin-app-image-external-embedded.spec.mjs
  (passes): launcher renders custom image_url, external_tab opens
  external URL via window.open, external_iframe navigates to
  /embedded/:id and renders <iframe src=externalUrl>.

Out of scope (pre-existing, not introduced here):
- 3 failing tests in executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts.
This commit is contained in:
riyadhafraa
2026-05-12 12:31:43 +00:00
parent a794f92e61
commit e7948012f4
8 changed files with 221 additions and 46 deletions
+28
View File
@@ -1,4 +1,5 @@
import { db } from "@workspace/db";
import { BUILTIN_APP_SLUGS } from "@workspace/db/built-in-apps";
import {
usersTable,
rolesTable,
@@ -225,6 +226,33 @@ async function main() {
},
];
// Drift guard: every built-in slug (those with hardcoded routes in the
// SPA, see lib/db/src/built-in-apps.ts) that this seed actually creates
// here must use the same `route` the SPA expects. We don't require ALL
// built-in slugs to be seeded (some, like "notes" / "my-orders" /
// "orders-incoming", are user-created later), but if we DO seed one,
// the route must match the hardcoded SPA route.
const expectedBuiltinRoutes: Record<string, string> = {
services: "/services",
notifications: "/notifications",
admin: "/admin",
notes: "/notes",
"my-orders": "/my-orders",
"orders-incoming": "/orders/incoming",
"executive-meetings": "/executive-meetings",
};
for (const a of apps) {
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
const expected = expectedBuiltinRoutes[a.slug];
if (expected && a.route !== expected) {
throw new Error(
`seed: built-in app "${a.slug}" must use route "${expected}" (got "${a.route}"). ` +
`Built-in slugs are listed in lib/db/src/built-in-apps.ts and have hardcoded SPA routes in artifacts/tx-os/src/App.tsx.`,
);
}
}
}
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
console.log("Apps created");