diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 799821c5bf..2e2d8b4358 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -15,6 +15,7 @@ import * as objects from './src/objects/index.js'; import { ShowcaseExternalDatasource } from './src/datasources/showcase-external.datasource.js'; import { ExternalCustomer, ExternalOrder } from './src/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/datasources/external-fixture.js'; +import { registerRecalcEndpoint } from './src/server/recalc-endpoint.js'; import { TaskViews, ProjectViews, InquiryViews } from './src/views/index.js'; import { ShowcaseApp } from './src/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; @@ -192,4 +193,6 @@ export default defineStack({ */ export const onEnable = async (ctx: unknown): Promise => { await setupShowcaseExternalDatasource(ctx as Parameters[0]); + // Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action. + registerRecalcEndpoint(ctx as Parameters[0]); }; diff --git a/examples/app-showcase/src/actions/index.ts b/examples/app-showcase/src/actions/index.ts index 2f760a55af..069ee8cf48 100644 --- a/examples/app-showcase/src/actions/index.ts +++ b/examples/app-showcase/src/actions/index.ts @@ -97,6 +97,7 @@ export const RecalcEstimateAction = defineAction({ objectName: task, type: 'api', target: '/api/v1/showcase/recalc', + successMessage: 'Estimate recalculated.', locations: ['record_more', 'record_section'], refreshAfter: true, }); diff --git a/examples/app-showcase/src/server/recalc-endpoint.ts b/examples/app-showcase/src/server/recalc-endpoint.ts new file mode 100644 index 0000000000..e5a8612ada --- /dev/null +++ b/examples/app-showcase/src/server/recalc-endpoint.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Custom REST endpoint backing the `showcase_recalc_estimate` **api** action. + * + * The showcase exercises every `ActionType`; `type: 'api'` means "POST to a + * custom endpoint". That endpoint has to exist, or the button 404s on click — + * a soft failure the build can't catch (an `api` action only needs a target + * *string*; the URL's reachability isn't statically verifiable). So we mount a + * real route here. + * + * Wiring: there is no declarative endpoint surface in a bundle, so we register + * imperatively against the `http.server` service. We do it on `kernel:ready` + * (the service is reliably available then, and Hono's route matcher is only + * frozen later on `kernel:listening`, so the route lands in time). + * + * Contract: the objectui ActionRunner POSTs the record as the JSON body for a + * string-target api action, so the body carries `id` + the record fields. + * We recompute `estimate_hours` from the schedule window (working at 8h/day) + * and persist it; `refreshAfter: true` on the action repaints the new value. + */ + +interface RecalcHostContext { + ql: { update: (object: string, data: Record, options?: unknown) => Promise }; + logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void; error?: (...a: unknown[]) => void }; + hook?: (event: string, handler: () => Promise | void) => void; + getService?: (name: string) => Promise; +} + +/** Inclusive day-count between two dates × 8h; falls back to one day. */ +function estimateFromWindow(start: unknown, end: unknown): number { + const s = typeof start === 'string' || start instanceof Date ? new Date(start as string) : undefined; + const e = typeof end === 'string' || end instanceof Date ? new Date(end as string) : undefined; + if (s && e && !Number.isNaN(s.getTime()) && !Number.isNaN(e.getTime()) && e >= s) { + const days = Math.round((e.getTime() - s.getTime()) / 86_400_000) + 1; + return Math.max(1, days) * 8; + } + return 8; +} + +export function registerRecalcEndpoint(ctx: RecalcHostContext): void { + const mount = async (): Promise => { + let server: { post?: (path: string, handler: (req: unknown, res: unknown) => unknown) => void } | undefined; + try { + server = await ctx.getService?.('http.server'); + } catch { + server = undefined; + } + if (!server || typeof server.post !== 'function') { + ctx.logger?.warn?.('[showcase] http.server unavailable — POST /api/v1/showcase/recalc not mounted'); + return; + } + + server.post('/api/v1/showcase/recalc', async (req: unknown, res: unknown) => { + const r = res as { + status: (code: number) => void; + json: (body: unknown) => void; + }; + try { + const body = ((req as { body?: Record })?.body) ?? {}; + const id = (body.id ?? body.recordId) as string | undefined; + if (!id) { + r.status(400); + r.json({ success: false, error: 'recordId required' }); + return; + } + const estimate_hours = estimateFromWindow(body.start_date, body.end_date); + await ctx.ql.update('showcase_task', { id, estimate_hours }, { where: { id } }); + r.json({ success: true, data: { id, estimate_hours } }); + } catch (err) { + r.status(500); + r.json({ success: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + + ctx.logger?.info?.('[showcase] mounted POST /api/v1/showcase/recalc'); + }; + + if (typeof ctx.hook === 'function') { + ctx.hook('kernel:ready', mount); + } else { + void mount(); + } +} diff --git a/examples/app-showcase/test/recalc-endpoint.test.ts b/examples/app-showcase/test/recalc-endpoint.test.ts new file mode 100644 index 0000000000..bcbb21f2f0 --- /dev/null +++ b/examples/app-showcase/test/recalc-endpoint.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { registerRecalcEndpoint } from '../src/server/recalc-endpoint.js'; + +/** + * Unit coverage for the custom REST endpoint behind the `showcase_recalc_estimate` + * **api** action (#2169 sibling: an api action whose endpoint was never mounted + * 404s on click). Drives the registration + handler with fakes — no live server. + */ +function harness() { + let kernelReady: (() => Promise | void) | undefined; + let routeHandler: ((req: unknown, res: unknown) => unknown) | undefined; + const updates: Array<{ object: string; data: Record }> = []; + const ctx = { + ql: { + update: async (object: string, data: Record) => { + updates.push({ object, data }); + return { id: data.id }; + }, + }, + logger: { info() {}, warn() {}, error() {} }, + hook: (event: string, handler: () => Promise | void) => { + if (event === 'kernel:ready') kernelReady = handler; + }, + getService: async (name: string) => + name === 'http.server' + ? { post: (_p: string, h: (req: unknown, res: unknown) => unknown) => { routeHandler = h; } } + : undefined, + }; + registerRecalcEndpoint(ctx as never); + return { + boot: async () => { await kernelReady?.(); }, + call: async (body: unknown) => { + let status = 200; + let json: unknown; + await routeHandler?.({ body }, { status: (c: number) => { status = c; }, json: (b: unknown) => { json = b; } }); + return { status, json }; + }, + updates, + hasRoute: () => routeHandler !== undefined, + }; +} + +describe('showcase recalc endpoint', () => { + it('registers the route on kernel:ready', async () => { + const h = harness(); + expect(h.hasRoute()).toBe(false); + await h.boot(); + expect(h.hasRoute()).toBe(true); + }); + + it('recomputes estimate from the schedule window (days × 8h) and persists it', async () => { + const h = harness(); + await h.boot(); + const res = await h.call({ id: 't1', start_date: '2026-06-16', end_date: '2026-07-02' }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ success: true, data: { id: 't1', estimate_hours: 136 } }); + expect(h.updates).toEqual([{ object: 'showcase_task', data: { id: 't1', estimate_hours: 136 } }]); + }); + + it('falls back to 8h when the window is missing', async () => { + const h = harness(); + await h.boot(); + const res = await h.call({ id: 't2' }); + expect(res.json).toEqual({ success: true, data: { id: 't2', estimate_hours: 8 } }); + }); + + it('rejects a request without a record id', async () => { + const h = harness(); + await h.boot(); + const res = await h.call({}); + expect(res.status).toBe(400); + expect(h.updates).toHaveLength(0); + }); +});