@aiquants/daily-report
v0.18.2
Published
Reusable daily-report feature package: shared types/schemas, React (Router v7 / v8) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).
Readme
@aiquants/daily-report
Reusable daily-report feature package: a virtual-scrolling report list/detail UI with optimistic updates and SSE real-time sync (React Router v7), plus a drizzle (mssql) server layer — all app-specific concerns (auth, user resolution, id obfuscation, redis, legacy tables, chrome) injected via DI ports.
Follows the @aiquants/authz-* DI layering — every app-specific concern (auth, user resolution, id obfuscation, redis, legacy tables) is injected via ports, so the package itself carries no application- or domain-specific knowledge. Packaged as one module with three entries:
| Entry | Contents | Environment |
| :--- | :--- | :--- |
| @aiquants/daily-report | Types, zod SSE schemas, business-date / text / comment utilities | isomorphic |
| @aiquants/daily-report/client | DailyReportPage, list/detail components, hooks, action context, route helpers | browser (React) |
| @aiquants/daily-report/server | createDailyReportServer (service + loaders/actions), defineDailyReportSchema, SSE fan-out reader, SQL result cache | node |
Install
Monorepo (already a pnpm workspace package):
// consumer package.json
"dependencies": { "@aiquants/daily-report": "workspace:*" }External projects:
pnpm add @aiquants/daily-report react react-dom react-router drizzle-orm zodreact / react-dom / react-router / drizzle-orm / zod are peer dependencies. Build / test: pnpm run build (tsup → dist) / pnpm run test (vitest).
Tailwind CSS
This package has no hand-written component CSS, so it ships no components-only artifact — only a standalone build for non-Tailwind hosts. Components carry Tailwind utility classes in JSX. Pick one consumption mode:
Tailwind v4 host (required if your app builds Tailwind itself) — add the package source to your app's scan targets so all utilities are generated in one canonical build, define the dark: variant, and map the shadcn tokens the components reference (bg-background, text-muted-foreground, ... do not exist in Tailwind's default theme, so those utilities are not generated unless the tokens are mapped):
/* app tailwind.css (Tailwind v4) */
@source "../node_modules/@aiquants/daily-report/src/**/*.{ts,tsx}";
/* monorepo: @source "../../../../packages/daily-report/src/**/*.{ts,tsx}"; */
@plugin "tailwindcss-animate";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: hsl(var(--background));
--color-muted-foreground: hsl(var(--muted-foreground));
/* map every shadcn token the components use; full set in src/styles/standalone.entry.css */
}The host must also:
- Load the
tailwindcss-animateplugin. The components useanimate-in/fade-in/zoom-in-95entrance animations; without@plugin "tailwindcss-animate"those classes are not generated and the animations silently no-op. - Define the shadcn CSS variables themselves (
--background,--muted,--radius, ...). - Import the
@aiquants/virtualscrollpeer CSS once. Do NOT@importthe standalone build alongside your own Tailwind build: it duplicates utilities your build also emits, and concatenated builds cannot reproduce the single-build canonical order (base utilities before variants), so base/variant winners flip regardless of@layerwrapping (e.g. a later duplicate.flexbeats an earlier.sm:hidden).
Non-Tailwind host — import the single self-contained standalone build (pnpm run build:css → dist/styles/daily-report.standalone.css):
@import "@aiquants/daily-report/styles/daily-report.standalone.css";The standalone build bundles every JSX utility (and maps the shadcn tokens), but not the shadcn variable values themselves and not the virtualscroll CSS. So the host must still: define the shadcn variables (--background, --muted, --radius, ...), toggle the .dark class on <html> for dark: variants, and import @aiquants/virtualscroll/styles/virtualscroll.standalone.css separately (kept out to avoid a stale embedded copy overriding the host's fresh one).
Required database schema
Seven tables: DailyReportHub (core/cross-source), DailyReportInternal (in-app content), DailyReportComment, DailyReportLabel, DailyReportHub_Label, DailyReportUserStatus (read status & stars), and the optional DailyReportAttachment (attachment metadata — omit it and the attachment feature degrades gracefully: attachments stays an empty array and the attachment endpoint returns 404 for every token).
Attachment delivery additionally requires the attachmentIdCodec and readAttachment DI ports on createDailyReportServer (see DailyReportServerConfig); the factory then exposes dailyReportServer.attachment.loader, which the host app must mount on its own byte-serving route (e.g. daily_report.api.attachment.$token — a route separate from the :endpoint JSON router, whose fixed Content-Type: application/json + CSP headers are incompatible with byte delivery).
Existing apps can inject their own drizzle models (structural typing — see DailyReportTables). Greenfield projects can generate definitions:
import { defineDailyReportSchema } from "@aiquants/daily-report/server"
const tables = defineDailyReportSchema("dbo_app", { userTable: Users })DailyReportHub.source_id_num is a computed column (TRY_CAST(source_id AS BIGINT)), used to join external legacy sources.
Server wiring (DI)
// app/services/daily-report/config.server.ts
import { createDailyReportServer, type DailyReportDb, type DailyReportRedisProvider } from "@aiquants/daily-report/server"
export const dailyReportServer = createDailyReportServer({
db: db as unknown as DailyReportDb, // drizzle mssql handle
tables: { hub, internal, comment, label, hubLabel, userStatus },
userTable: Users, // { id, displayName }
resolveUserId: async (externalId) => {/* External ID (e.g., OAuth sub) -> internal numeric ID */},
encodeUserId: (id) => encodeId(id), // Internal ID obfuscation (e.g. sqids)
authenticate: authenticateInLoader, // DailyReportAuthenticate (overloaded): string mode => { user, cookie? } / null mode => { user?, cookie? }
redis: redisProvider as unknown as DailyReportRedisProvider, // Optional (disables SSE/epoch)
externalSources: [ // Optional: ingest legacy daily report tables from external systems
{ sourceType: "legacy", table: LegacyReport, idColumn: LegacyReport.reportId, mapRow: mapLegacyRow },
],
draftLabelNames: ["Draft", "Work in Progress"], // Draft label names (can specify a single string or array of candidates)
enableDevCacheClear: import.meta.env.DEV, // Optional: allow the dev-only `intent=clearCache` (default false → 400)
})Route mounts (React Router v7, flexible file convention):
// daily_report._index/loader.server.ts
export const loader = async (args) => {
const r = await dailyReportServer.index.loader(args)
return data(r.data, { headers: r.headers })
}
// daily_report.api.$endpoint/route.tsx
export const loader = (args) => dailyReportServer.api.loader(args)
export const action = (args) => dailyReportServer.api.action(args)
// sse.daily_report.$endpoint/route.tsx
export const loader = (args) => dailyReportServer.sse.loader(args)DI ports
authenticate(request, { failureRedirect })— Session verification, declared as the overloadedDailyReportAuthenticate. WithfailureRedirect: stringthe implementation must throw a redirect on unauthenticated requests, so a normal return always carriesuser(typed as required — leaving it optional would force callers to write an unreachable!userguard). WithfailureRedirect: nullit must not redirect and resolves withoutuserinstead; forward the returnedcookieon unauthenticated responses too, otherwise a destroyed session lingers in the browser.resolveUserId(externalId)— External ID → internal numeric ID (null= unregistered). In-process caching can be disabled withdisableUserIdCache(useful for testing).encodeUserId(id)— Obfuscates IDs sent to the client. Inject app-level implementation to preserve existing ID namespaces.redis—getClient()(get/incr/xAdd/xRange/xRevRange) +createClient()(blocking xRead). Structurally matches anode-redisv5 client. If omitted, SSE publishing is skipped (with a warning) and epoch is disabled.externalSources[]— WhenHub.source_typematches, performs aLEFT JOINonHub.source_id_num = idColumnand converts fields viamapRow.draftLabelName/draftLabelNames— Database label names representing draft states (single string or array of candidates like["Draft", "Work in Progress"]). Used for server-side cross-user visibility filtering (hiding drafts from other users) andisDraftevaluation.resolveVisibleSourceTypes(request)— Optional row-level authorization port. Returns theHub.source_typevalues this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See Source-type visibility below.enableDevCacheClear— Gates the dev-onlyPOST /actionintent=clearCache(flush every worker's cache). Defaultfalse→ the handler returns400before touching the service. Wireimport.meta.env.DEVto enable it only in development (any authenticated user could otherwise flush all caches without limit).- Primary tuning parameters:
idsTtlMs(180s) /businessDateTtlMs(300s) /streamKey/streamMaxLen/loginRedirectPath.
Source-type visibility
Restrict which report categories a viewer may see, without the package depending on any authorization library. The port receives the request and returns plain strings; your app decides the policy.
createDailyReportServer({
// …existing config
resolveVisibleSourceTypes: async (request, { reason }) => {
// `reason === "refresh"` is the periodic re-check of a live SSE connection: bypass any
// request-scoped cache there, or a revoked grant keeps streaming until the client reconnects.
const grants = await yourAuthz.grantsFor(request, { skipCache: reason === "refresh" })
return grants.canReadLegacy ? ["legacy"] : []
},
})Contract
| Return value | Meaning |
| --- | --- |
| undefined / null | Unrestricted — every source type (the default when the port is not injected). |
| ["legacy", "Internal"] | Only those source types are visible (bodies and comments). |
| [] | Nothing is visible (zero rows) — not the same as null. |
| { read: [...] } | Same as returning the bare array — the comment dimension follows read. |
| { read: [...], comment: [...] } | Bodies and comments restricted independently. See Comment visibility. |
| throws | Treated as [] (deny all). The failure is logged; an authorization-store outage never falls open. |
- Vocabulary: the strings are
DailyReportHub.source_typevalues — thesourceTypeof each entry you registered inexternalSources, plus"Internal"which the package writes for reports it creates. They are not your authorization resource keys; mapping a resource key (e.g.report_legacy) onto a source type (e.g.legacy) is the consuming app's job. Read the canonical set at boot fromdailyReportServer.knownSourceTypesand assert your mapping against it — an unknown token silently matches zero rows. - Matching is case-insensitive on both the JS and SQL sides (both fold to lower case), so it does not depend on the database collation. Surrounding whitespace is trimmed from the tokens you return but never from the stored column value — the two sides fold identically, and a padded stored value is simply invisible (fail-closed) rather than visible on one path and hidden on the other.
- Where it applies: the SQL predicate is injected in the service layer, so it holds for every delivery path (including attachment bytes) rather than only the HTTP handlers. It also gates the mutations that act on someone else's report (
addComment,deleteComment, star, read); a hidden report is reported as404, never403. - How it reaches the service: the resolved set is wrapped in a
DailyReportViewerScopeand passed as the first argument of every gated service method. The class is nominal (it holds a private field), so{ visible: null },{ ...scope, visible: null }andnew DailyReportViewerScope(...)are all type errors — the only ways to obtain one areDailyReportViewerScope.restrictTo(tokens)— which acceptsreadonly string[]only, so it cannot be handed a nullish value and quietly widen — andDailyReportViewerScope.unrestricted(auditReason). Because a barenullis not spellable as a visibility value,grep -rn "DailyReportViewerScope.unrestricted("enumerates every unfiltered read in a codebase, each carrying a written reason. - Performance: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast — cache grants per session/user with a short TTL (≤ 60s). A live SSE connection re-resolves on its existing keep-alive tick (~60s) to bound how long a revoked grant keeps streaming; that call passes
reason: "refresh"and reuses the connection's originalRequest, so a cache keyed on request identity must be bypassed whenreason === "refresh"or the re-check silently returns the stale set. - SSE latency: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within ~60s. Events dropped before a widening are not replayed; the next read (which re-resolves per request) restores them.
Comment visibility
Return { read, comment } from the same port to restrict reading and writing comments independently of the report bodies — this is what connects a per-category comment permission (e.g. daily_report_legacy_comment) to actual access control.
resolveVisibleSourceTypes: async (request, { reason }) => {
const grants = await yourAuthz.grantsFor(request, { skipCache: reason === "refresh" })
return {
read: grants.readableSourceTypes, // e.g. ["legacy", "Internal"]
comment: grants.commentableSourceTypes, // e.g. ["Internal"] — omit the key to follow `read`
}
},| comment | Meaning |
| --- | --- |
| omitted | Follows read — identical to returning a bare array. This is the default and the only spelling for "same as bodies". |
| [] | Comments are invisible and cannot be written, while the bodies stay readable. |
| ["Internal"] | Only those source types' comments are visible/writable. Narrowed to read ∩ comment at construction, since comments only ever ship inside an already-read-filtered report. |
nullis rejected by the type system — a second spelling for "followread" would make the dimension four-state and reintroduce exactly the unrestricted-vs-deny-all confusion the tri-state contract exists to prevent. Anullthat slips through at runtime fails closed.- What it covers:
DailyReportDetailcarries comments in two structurally different fields —postedComments(the package's own comment table) andexternalComments(JSON supplied by anexternalSourcesadapter). Both are gated. They are emptied, never omitted: the keys are required by the SSE schema, and dropping them would make a receiver discard the whole message. - Live events:
comment-add/comment-deleteare judged on the comment dimension, and the report payload embedded inreport-create/report-update/report-publishhas its two comment arrays emptied per viewer at delivery time. - Your own comments are always visible and always deletable (
postedCommentsonly — theexternalCommentsarray carries no user identity, so it is all-or-nothing per source type). Hiding them would strand existing comments as undeletable the moment a grant is revoked, with no security gain. - Writing to external sources remains impossible regardless —
addCommentrejects any non-Internalsource type outright, so for external categories the comment dimension effectively controls reading. - Cache: the dimension is folded into the cache-key digest, so revoking only the comment grant invalidates immediately. When
commentfollowsread(the default) the digest is byte-identical to before, so existing deployments see no cache churn.
Client wiring
// daily_report._index/route.tsx
import { createDailyReportClientLoader, DailyReportPage, dailyReportShouldRevalidate } from "@aiquants/daily-report/client"
export const shouldRevalidate = dailyReportShouldRevalidate
export const clientLoader = createDailyReportClientLoader() // Bootstraps the module-resident ids NDJSON stream session
export default function Route() {
const { user, userId } = useLoaderData<typeof clientLoader>()
return (
<YourAppLayout>
<DailyReportPage
user={user}
userId={userId}
config={{
renderHeader: ({ title, annotation }) => <YourHeader title={title} annotation={annotation} />,
showDevControls: import.meta.env.DEV, // Toggle SSE subscriptions, etc.
draftLabelName: "Draft", // Label name for draft states (matches target DB)
fieldLabels: { // Card/detail header labels (customizable per key)
businessDate: "Date", content: "Body", comments: "Comments", /* ... override to match app locale */
},
onError: (info) => yourToast(info.message), // Optional: route mutation failures to your own toast
// apiBasePath: "/daily_report/api", ssePath: "/sse/daily_report/updates"
}}
/>
</YourAppLayout>
)
}DailyReportPage is layout-agnostic (header rendering via renderHeader slot, error boundaries/footer control managed by the app). For fine-grained usage, DailyReportActionProvider, DailyReportResolvedContent, useDailyReportDetail, etc. can be imported individually.
Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject config.onError(info: DailyReportErrorInfo) to route them into your own toast/notification system, or omit it to use the package's built-in role="alert" banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose DailyReportActionProvider yourself instead of using DailyReportPage, wrap it in DailyReportErrorProvider (both are exported from @aiquants/daily-report/client) so onError / the banner work.
The report id list is not part of the loader data: a module-resident NDJSON stream session (GET {apiBasePath}/ids-stream, resilient client with cursor resume + exponential backoff) supplies it.
createDailyReportClientLoader warms an existing session (primeDailyReportIdsStreamSession) and then bootstraps (bootstrapDailyReportIdsStreamSession): when no session exists it is created at loader time so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
If your app overrides config.apiBasePath, pass the same value to createDailyReportClientLoader({ apiBasePath }) — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount ensureDailyReportIdsStreamSession.
DailyReportPage subscribes via useDailyReportIdsStream, rendering the list as soon as the first chunk arrives (on cache misses the server races a fast TOP 200 first page against the cached full query, so first paint does not wait for the full id scan).
There is no dailyReportIds prop and no deferred /ids JSON fetch (the old ids endpoint was removed).
Field Labels (fieldLabels)
Card and detail headings (id / businessDate / author / visitTime / createdAt / updatedAt / updatedBy / category / categoryInfo / creationCategory / customer / subject / content / interviewers / comments / attachments / attachmentMissing / attachmentDownload / tabArticle / tabRelations / relationsEmpty — all 21 keys of DailyReportFieldLabels) are fully dependency-injected.
The package includes neutral English defaults (Business date / Customer / Content, etc.), and consuming applications override headings via fieldLabels. This ensures the package itself carries no domain-specific wording. Partial overrides merge deeply over defaults (DailyReportConfigProvider performs a 1-level deep merge).
External Source Badge Configuration (sourceTypeConfigs)
Display parameters (label names and badge CSS classes) for reports ingested from external sources can be overridden using sourceTypeConfigs. This prevents the package from hardcoding specific third-party service names or designs, leaving display customization to the host application.
sourceTypeConfigs: {
Internal: {
label: "Native",
badgeClassName: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
},
ExternalCRM: {
label: "External CRM",
badgeClassName: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
},
LegacyPortal: {
label: "Legacy Portal",
badgeClassName: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
},
}Troubleshooting: Hook errors / Multiple React instances (Cannot read properties of null)
If you encounter TypeError: Cannot read properties of null (reading 'useMemo') or "Invalid hook call", it means multiple React instances are loaded in memory. This often happens because the bundler/framework (Vite/React Router v7) resolves separate React packages for your application and this dependency (especially when linked via monorepo or npm link).
To fix this, update your consumer application's vite.config.ts to deduplicate React:
import path from "node:path"
import { defineConfig } from "vite"
export default defineConfig({
resolve: {
// Force Vite to always use the root React instance
dedupe: ["react", "react-dom", "react-router", "react-router-dom"],
alias: {
react: path.resolve(__dirname, "./node_modules/react"),
"react-dom": path.resolve(__dirname, "./node_modules/react-dom"),
}
},
optimizeDeps: {
// Exclude the package so Vite doesn't optimize it separately under .vite/deps
exclude: [
"@aiquants/daily-report",
"@aiquants/virtualscroll",
"@aiquants/swipe-overlay",
],
},
ssr: {
// For SSR environments like React Router v7 / Remix
noExternal: [
"@aiquants/daily-report",
"@aiquants/virtualscroll",
"@aiquants/swipe-overlay",
],
}
})Realtime Architecture
- Mutation services publish to Redis Stream (
daily-report:sse-stream) in sequence:invalidate -> epoch increment -> SSE publish. DailyReportSseReaderuses a single blockingxReadloop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections).sse.loaderimplements catch-up (xRange) usingLast-Event-ID/lastEventIdand 5-second keep-alive pings.recipientRawUserIdis filtered server-side and removed before transmission to prevent internal ID leaks.- On the client,
useDailyReportSseConnectionhandles exponential backoff reconnects, and the action context correlates optimistic updates with SSE echoes usingclientTempId.
API Surface (Summary)
- server:
createDailyReportServer/createDailyReportService/createDailyReportHandlers/defineDailyReportSchema/DailyReportSseReader/SqlResultCache/transformJsonArray/jsonResponseWithETag/generateETag/isStreamIdLte - client:
DailyReportPage/DailyReportResolvedContent/DailyReportList/DailyReportDetailList/DailyReportActionProvider/useDailyReportActionContext/useDailyReportDetail/useDailyReportPrefetch/useDailyReportComments/useDailyReportSseConnection/useDailyReportIdsStream/DailyReportIdsStreamStatus/DailyReportIdsStreamClient/DailyReportConfigProvider/createDailyReportClientLoader/dailyReportShouldRevalidate - shared:
DailyReportItem/DailyReportDetail/DailyReportUser/dailyReportSseMessageSchema(8 discriminated union types) /normalizeBusinessDateKey/mergeComments
MIT
